mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-12 15:16:29 +02:00
Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0da9cf09 | ||
|
|
d55974c352 | ||
|
|
a898c22f4b | ||
|
|
b82e8bedfc | ||
|
|
7453cefd94 | ||
|
|
1ed6b958cb | ||
|
|
57896ab176 | ||
|
|
5c370b3914 | ||
|
|
182e35adc7 | ||
|
|
d0a360fd80 | ||
|
|
2fbc0625de | ||
|
|
d3d20a4e20 | ||
|
|
2c088d3504 | ||
|
|
6f9728f2d4 | ||
|
|
30552fd202 | ||
|
|
9826c4e943 | ||
|
|
bb9445bd0f | ||
|
|
1f7e66f4cb | ||
|
|
2a34e918a0 | ||
|
|
21c0d924ab | ||
|
|
c8d5ee6565 | ||
|
|
3d8fc7ca7b | ||
|
|
246b5b93f8 | ||
|
|
2183c0980b | ||
|
|
4ae301710d | ||
|
|
5f9390c210 | ||
|
|
0f3a03aab7 | ||
|
|
02f455b0cc | ||
|
|
ffddf60184 | ||
|
|
482840b8bb | ||
|
|
a3637cf2b6 | ||
|
|
48669cdb34 | ||
|
|
a953845ba7 | ||
|
|
8d71534839 | ||
|
|
d110118961 | ||
|
|
fa1ed2bc0c | ||
|
|
3f28978dad | ||
|
|
02cd121465 | ||
|
|
5481c300b2 | ||
|
|
7b75257a4a | ||
|
|
c02e5cad73 | ||
|
|
dee03c0f9f | ||
|
|
d1159764f6 | ||
|
|
eacb07988d | ||
|
|
a375766ac2 | ||
|
|
9b9276e752 | ||
|
|
753a2ab2b7 | ||
|
|
0cef5f79ee | ||
|
|
b11a8dfe54 | ||
|
|
2d1c94f1ef | ||
|
|
e14e850e10 | ||
|
|
3176391693 | ||
|
|
5277300943 | ||
|
|
878e1ff290 | ||
|
|
8d453010a4 | ||
|
|
e2f6030590 | ||
|
|
bf3f8706f8 | ||
|
|
5c9b4abab2 | ||
|
|
9fb4862a45 | ||
|
|
65df6897a6 | ||
|
|
529810f2f4 | ||
|
|
df0ff4f134 | ||
|
|
6c949a9602 | ||
|
|
f933f46283 | ||
|
|
4080907d2b | ||
|
|
ed5cd21cb6 | ||
|
|
aa8278e1d5 | ||
|
|
0f526fce6c | ||
|
|
15d471e520 | ||
|
|
c47e94813d | ||
|
|
c979cbcac7 | ||
|
|
6b2a1dfd84 | ||
|
|
7948d3144a | ||
|
|
d499098c4f | ||
|
|
42be442385 | ||
|
|
e2ec6a5be8 | ||
|
|
438cef8cf9 | ||
|
|
7bacf7cdc9 | ||
|
|
c5e76972aa | ||
|
|
7ca8e0d437 | ||
|
|
a98852e279 | ||
|
|
d0e9c6dc57 | ||
|
|
ac70f380a6 | ||
|
|
34cf9d6181 | ||
|
|
db4296533a | ||
|
|
6381f43f01 | ||
|
|
f4fb31d7a1 | ||
|
|
9e22f9639a | ||
|
|
9b854d3034 | ||
|
|
2c88a44a53 | ||
|
|
0c2b86c8e7 | ||
|
|
74752bbd2f | ||
|
|
ad396b4155 | ||
|
|
5ff1740b5b | ||
|
|
e0ab3f0c92 | ||
|
|
9b77e91d79 | ||
|
|
d187121645 | ||
|
|
a22f2108c6 | ||
|
|
bf24869c6a | ||
|
|
4e9a370ff6 | ||
|
|
1aed6f3c2e | ||
|
|
6367d50d76 | ||
|
|
f33ed27419 | ||
|
|
870c8cb158 | ||
|
|
0b9d7925b5 | ||
|
|
16b625f8b4 | ||
|
|
16d301a783 | ||
|
|
212bbaf44c | ||
|
|
1d6037003a | ||
|
|
6f4b23b40b | ||
|
|
4e82766ba4 | ||
|
|
dc86db5206 | ||
|
|
5a75ea723b | ||
|
|
d59f216c26 | ||
|
|
160edcc1cd | ||
|
|
59d597de8a | ||
|
|
806351b6c1 | ||
|
|
e7909a0dbd | ||
|
|
d6d44be1b7 | ||
|
|
53efaf125c | ||
|
|
1fb0123ed7 | ||
|
|
a0659a277a | ||
|
|
77064cc2f8 | ||
|
|
1954790808 | ||
|
|
4263643200 | ||
|
|
43ec57c769 | ||
|
|
302dad2016 | ||
|
|
fdb8b498cb | ||
|
|
f6af59b044 | ||
|
|
ad1ed132d1 | ||
|
|
466d456760 | ||
|
|
6bc3b38b56 | ||
|
|
39b91911cb | ||
|
|
e85989e9d9 | ||
|
|
e7f672899b | ||
|
|
9538eba64e | ||
|
|
b37b271fce | ||
|
|
77be752ff1 | ||
|
|
725a47268e | ||
|
|
2ba215a6d7 | ||
|
|
6533a1b98d | ||
|
|
1f2f5a41d4 | ||
|
|
4e7680e322 | ||
|
|
f32591c3d1 | ||
|
|
6ec217263d | ||
|
|
8899b90725 | ||
|
|
7ece7e730a | ||
|
|
d55b98b187 | ||
|
|
d9674a2d77 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -5,7 +5,7 @@ env:
|
||||
# CICD_INTERMEDIATES_DIR: "_cicd-intermediates"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# for multiarch gcc compatibility
|
||||
VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
169
.github/workflows/flutter-build.yml
vendored
169
.github/workflows/flutter-build.yml
vendored
@@ -31,14 +31,15 @@ env:
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2025.01.13
|
||||
# vcpkg version: 2025.08.27
|
||||
# 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.1"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.3"
|
||||
NDK_VERSION: "r27c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -391,6 +392,13 @@ jobs:
|
||||
ls -l ./libs/portable/Runner.res;
|
||||
fi
|
||||
|
||||
- name: Upload unsigned
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||
path: Release
|
||||
|
||||
- name: Sign rustdesk files
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
shell: bash
|
||||
@@ -424,80 +432,6 @@ jobs:
|
||||
files: |
|
||||
./SignOutput/rustdesk-*.exe
|
||||
|
||||
build-for-macOS-arm64-selfhost:
|
||||
# use build-for-macOS instead
|
||||
if: false
|
||||
runs-on: [self-hosted, macOS, ARM64]
|
||||
needs: [generate-bridge]
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Build rustdesk
|
||||
run: |
|
||||
./build.py --flutter --hwcodec --unix-file-copy-paste
|
||||
|
||||
- name: create unsigned dmg
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
CREATE_DMG="$(command -v create-dmg)"
|
||||
CREATE_DMG="$(readlink -f "$CREATE_DMG")"
|
||||
sed -i -e 's/MAXIMUM_UNMOUNTING_ATTEMPTS=3/MAXIMUM_UNMOUNTING_ATTEMPTS=7/' "$CREATE_DMG"
|
||||
create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}-arm64.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
|
||||
|
||||
- name: Upload unsigned macOS app
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-arm64
|
||||
path: rustdesk-${{ env.VERSION }}-arm64.dmg # can not upload the directory directly or tar.gz file, which destroy the link structure, causing the codesign failed
|
||||
|
||||
- name: Codesign app and create signed dmg
|
||||
if: env.MACOS_P12_BASE64 != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
# Patch create-dmg to give more attempts to unmount image
|
||||
CREATE_DMG="$(command -v create-dmg)"
|
||||
CREATE_DMG="$(readlink -f "$CREATE_DMG")"
|
||||
sed -i -e 's/MAXIMUM_UNMOUNTING_ATTEMPTS=3/MAXIMUM_UNMOUNTING_ATTEMPTS=7/' "$CREATE_DMG"
|
||||
# start sign the rustdesk.app and dmg
|
||||
rm -rf *.dmg || true
|
||||
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict ./flutter/build/macos/Build/Products/Release/RustDesk.app -vvv
|
||||
create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
|
||||
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv
|
||||
# notarize the rustdesk-${{ env.VERSION }}.dmg
|
||||
rcodesign notary-submit --api-key-path ~/.p12/api-key.json --staple rustdesk-${{ env.VERSION }}.dmg
|
||||
|
||||
- name: Rename rustdesk
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
for name in rustdesk*??.dmg; do
|
||||
mv "$name" "${name%%.dmg}-aarch64.dmg"
|
||||
done
|
||||
|
||||
- name: Publish DMG package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
files: |
|
||||
rustdesk*-aarch64.dmg
|
||||
|
||||
build-rustdesk-ios:
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
name: build rustdesk ios ipa
|
||||
@@ -617,63 +551,6 @@ jobs:
|
||||
# files: |
|
||||
# flutter/build/ios/ipa/*.ipa
|
||||
|
||||
build-rustdesk-ios-selfhost:
|
||||
#if: ${{ inputs.upload-artifact }}
|
||||
if: false
|
||||
runs-on: [self-hosted, macOS, ARM64]
|
||||
needs: [generate-bridge]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# $VCPKG_ROOT/vcpkg install --triplet arm64-ios --x-install-root="$VCPKG_ROOT/installed"
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Build rustdesk lib
|
||||
run: |
|
||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
|
||||
- name: Build rustdesk
|
||||
# ios sdk not installed on this machine, I will install it later after I am back home
|
||||
if: false
|
||||
shell: bash
|
||||
run: |
|
||||
pushd flutter
|
||||
# flutter build ipa --release --obfuscate --split-debug-info=./split-debug-info --no-codesign
|
||||
# for easy debugging
|
||||
flutter build ipa --release --no-codesign
|
||||
|
||||
# - name: Upload Artifacts
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: actions/upload-artifact@master
|
||||
# with:
|
||||
# name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
# path: flutter/build/ios/ipa/*.ipa
|
||||
|
||||
# - name: Publish ipa package
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: softprops/action-gh-release@v1
|
||||
# with:
|
||||
# prerelease: true
|
||||
# tag_name: ${{ env.TAG_NAME }}
|
||||
# files: |
|
||||
# flutter/build/ios/ipa/*.ipa
|
||||
|
||||
build-for-macOS:
|
||||
name: ${{ matrix.job.target }}
|
||||
@@ -692,7 +569,7 @@ jobs:
|
||||
}
|
||||
- {
|
||||
target: aarch64-apple-darwin,
|
||||
os: macos-latest,
|
||||
os: macos-14,
|
||||
# extra-build-args: "--disable-flutter-texture-render", # disable this for mac, because we see a lot of users reporting flickering both on arm and x64, and we can not confirm if texture rendering has better performance if htere is no vram, https://github.com/rustdesk/rustdesk/issues/6296
|
||||
extra-build-args: "--screencapturekit",
|
||||
arch: aarch64,
|
||||
@@ -746,7 +623,7 @@ jobs:
|
||||
|
||||
- name: Install build runtime
|
||||
run: |
|
||||
brew install llvm create-dmg nasm cmake gcc wget ninja
|
||||
brew install llvm create-dmg nasm
|
||||
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
|
||||
if command -v pkg-config &>/dev/null; then
|
||||
echo "pkg-config is already installed"
|
||||
@@ -886,6 +763,7 @@ jobs:
|
||||
needs:
|
||||
- build-for-macOS
|
||||
- build-for-windows-flutter
|
||||
- build-for-windows-sciter
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
steps:
|
||||
@@ -907,9 +785,15 @@ jobs:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
|
||||
- name: Combine unsigned app
|
||||
run: |
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||
|
||||
- name: Publish unsigned app
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -1768,6 +1652,14 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Modify vcpkg.json for armv7
|
||||
if: matrix.job.vcpkg-triplet == 'arm-linux'
|
||||
run: |
|
||||
# Replace the baseline in vcpkg.json with ARMV7_VCPKG_COMMIT_ID for armv7 builds
|
||||
sed -i 's/"baseline": ".*"/"baseline": "${{ env.ARMV7_VCPKG_COMMIT_ID }}"/' vcpkg.json
|
||||
echo "Modified vcpkg.json for armv7 build:"
|
||||
grep -A 2 -B 2 '"baseline"' vcpkg.json
|
||||
|
||||
- name: Free Space
|
||||
run: |
|
||||
df -h
|
||||
@@ -1853,11 +1745,12 @@ jobs:
|
||||
rm -rf vcpkg
|
||||
git clone https://github.com/microsoft/vcpkg
|
||||
pushd vcpkg
|
||||
git reset --hard ${{ env.VCPKG_COMMIT_ID }}
|
||||
# build vcpkg helper executable with gcc-8 for arm-linux but use prebuilt one on x64-linux
|
||||
if [ "${{ matrix.job.vcpkg-triplet }}" = "arm-linux" ]; then
|
||||
git reset --hard ${{ env.ARMV7_VCPKG_COMMIT_ID }}
|
||||
CC=/usr/bin/gcc-8 CXX=/usr/bin/g++-8 sh bootstrap-vcpkg.sh -disableMetrics
|
||||
else
|
||||
git reset --hard ${{ env.VCPKG_COMMIT_ID }}
|
||||
sh bootstrap-vcpkg.sh -disableMetrics
|
||||
fi
|
||||
popd
|
||||
|
||||
6
.github/workflows/playground.yml
vendored
6
.github/workflows/playground.yml
vendored
@@ -16,8 +16,8 @@ env:
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
|
||||
VERSION: "1.4.1"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.3"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
- name: Install build runtime
|
||||
run: |
|
||||
brew install llvm create-dmg nasm cmake gcc wget ninja pkg-config
|
||||
brew install llvm create-dmg nasm pkg-config
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
|
||||
4
.github/workflows/winget.yml
vendored
4
.github/workflows/winget.yml
vendored
@@ -10,6 +10,6 @@ jobs:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
with:
|
||||
identifier: RustDesk.RustDesk
|
||||
version: "1.4.1"
|
||||
release-tag: "1.4.1"
|
||||
version: "1.4.3"
|
||||
release-tag: "1.4.3"
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
867
Cargo.lock
generated
867
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.1"
|
||||
version = "1.4.3"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -144,6 +144,10 @@ core-graphics = "0.22"
|
||||
include_dir = "0.7"
|
||||
fruitbasket = "0.10"
|
||||
objc_id = "0.1"
|
||||
# If we use piet "0.7" here, we must also update core-graphics to "0.24".
|
||||
piet = "0.6"
|
||||
piet-coregraphics = "0.6"
|
||||
foreign-types = "0.3"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
tray-icon = { git = "https://github.com/tauri-apps/tray-icon" }
|
||||
@@ -155,6 +159,11 @@ keepawake = { git = "https://github.com/rustdesk-org/keepawake-rs" }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
wallpaper = { git = "https://github.com/rustdesk-org/wallpaper.rs" }
|
||||
tiny-skia = "0.11"
|
||||
softbuffer = "0.4"
|
||||
fontdb = "0.23"
|
||||
bytemuck = "1.23"
|
||||
ttf-parser = "0.25"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
# https://github.com/rustdesk/rustdesk-server-pro/issues/189, using native-tls for better tls support
|
||||
@@ -181,6 +190,7 @@ nix = { version = "0.29", features = ["term", "process"]}
|
||||
gtk = "0.18"
|
||||
termios = "0.3"
|
||||
terminfo = "0.8"
|
||||
winit = "0.30"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.13"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
Chat with us: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Yet another remote desktop solution, written in Rust. Works out of the box with no configuration required. You have full control of your data, with no concerns about security. You can use our rendezvous/relay server, [set up your own](https://rustdesk.com/server), or [write your own rendezvous/relay server](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.1
|
||||
version: 1.4.3
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.1
|
||||
version: 1.4.3
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
7
build.rs
7
build.rs
@@ -68,11 +68,8 @@ fn install_android_deps() {
|
||||
}
|
||||
path.push(target);
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"cargo:rustc-link-search={}",
|
||||
path.join("lib").to_str().unwrap()
|
||||
)
|
||||
"cargo:rustc-link-search={}",
|
||||
path.join("lib").to_str().unwrap()
|
||||
);
|
||||
println!("cargo:rustc-link-lib=ndk_compat");
|
||||
println!("cargo:rustc-link-lib=oboe");
|
||||
|
||||
137
docs/CODE_OF_CONDUCT-DE.md
Normal file
137
docs/CODE_OF_CONDUCT-DE.md
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
# Verhaltenskodex (Code of Conduct) für Mitwirkende
|
||||
|
||||
## Unsere Verpflichtung
|
||||
|
||||
Wir als Mitglieder, Mitwirkende und Führungskräfte verpflichten uns,
|
||||
die Teilnahme unserer Community zu einer Erfahrung zu machen,
|
||||
die für alle frei von Belästigungen ist, unabhängig von Alter, Körpergröße,
|
||||
sichtbarer oder unsichtbarer Behinderung, ethnischer Zugehörigkeit,
|
||||
Geschlechtsmerkmalen, Geschlechtsidentität und -ausdruck, Erfahrungsniveau,
|
||||
Bildung, sozioökonomischem Status, Nationalität, persönlichem Erscheinungsbild,
|
||||
Rasse, Religion oder sexueller Identität und Orientierung.
|
||||
|
||||
Wir verpflichten uns, so zu handeln und zu interagieren, dass wir zu einer offenen,
|
||||
einladenden, vielfältigen, integrativen und lebendigen Gemeinschaft beitragen.
|
||||
|
||||
## Unsere Standards
|
||||
|
||||
Beispiele für Verhaltensweisen, die zu einem positiven Umfeld für unsere
|
||||
Gemeinschaft beitragen, sind:
|
||||
|
||||
* Empathie und Freundlichkeit gegenüber anderen Menschen zu zeigen
|
||||
* Respektvoll gegenüber anderen Meinungen, Sichtweisen und Erfahrungen zu sein
|
||||
* Das Vergeben von sowie das großzügige Empfangen von konstruktivem Feedback
|
||||
* Verantwortung übernehmen, sich bei den Betroffenen entschuldigen
|
||||
und aus den Erfahrungen lernen
|
||||
* Nicht darauf zu achten, was das Beste für sich selbst,
|
||||
sondern zu Achten, was das Beste für die gesamte Community ist
|
||||
|
||||
Beispiele für nicht akzeptables Verhalten sind:
|
||||
|
||||
* Die Verwendung sexualisierter bzw. anstößiger Sprache oder Bilder
|
||||
sowie sexuelle Aufmerksamkeit oder Annäherungsversuche jeglicher Art
|
||||
* Trolling, beleidigende oder herabwürdigende Kommentare
|
||||
sowie persönliche oder politische Angriffe
|
||||
* Öffentliche sowie private Belästigung
|
||||
* Das Teilen privater Informationen anderer Leute ohne deren explizite Zustimmung,
|
||||
wie bspw. die physische oder die E-Mail-Adresse
|
||||
* Anderes Verhalten, das in einem professionellen Umfeld begründeter Weise als
|
||||
unangemessen angesehen werden könnte
|
||||
|
||||
## Durchsetzungsbefugnisse
|
||||
|
||||
Die Leiter der Community sind dafür verantwortlich, unsere Standards für
|
||||
akzeptables Verhalten zu klären und durchzusetzen und werden angemessene
|
||||
und faire Korrekturmaßnahmen ergreifen, wenn sie ein Verhalten als unangemessen,
|
||||
bedrohlich, beleidigend oder schädlich erachten.
|
||||
|
||||
Die Leiter der Community haben das Recht und die Pflicht, Kommentare, Commits,
|
||||
Code, Wiki-Bearbeitungen, Issues und andere Beiträge, die nicht mit dem
|
||||
Verhaltenskodex vereinbar sind, zu entfernen, zu bearbeiten oder abzulehnen.
|
||||
Sie werden, falls angebracht, die Gründe für Moderationsentscheidungen mitteilen.
|
||||
|
||||
## Geltungsbereich
|
||||
|
||||
Dieser Verhaltenskodex gilt in allen Community-Bereichen und auch dann, wenn
|
||||
eine Person die Community offiziell in öffentlichen Bereichen vertritt.
|
||||
Beispiele für die Vertretung unserer Community sind die Verwendung einer
|
||||
offiziellen E-Mail-Adresse, das Posten über einen offiziellen
|
||||
Social-Media-Account oder die Tätigkeit als ernannter
|
||||
Vertreter bei einer Online- oder Präsenzveranstaltung.
|
||||
|
||||
## Geltendmachung
|
||||
|
||||
Fälle von missbräuchlichem, belästigendem oder anderweitig inakzeptablem Verhalten können
|
||||
den für die Durchsetzung zuständigen Community-Leitern
|
||||
unter [info@rustdesk.com](mailto:info@rustdesk.com) gemeldet werden.
|
||||
Jeder Fall wird umgehend und fair geprüft und untersucht.
|
||||
|
||||
## Richtlinien zur Geltendmachung
|
||||
|
||||
Die Community-Leiter werden die folgenden Community-Auswirkungsrichtlinien befolgen,
|
||||
um die Konsequenzen für jede Handlung zu bestimmen, die sie als Verstoß gegen diesen
|
||||
Verhaltenskodex ansehen:
|
||||
|
||||
### 1. Korrektur
|
||||
|
||||
**Auswirkungen auf die Community**: Verwendung unangemessener Sprache oder anderes
|
||||
Verhalten, welches als unprofessionell oder in der Community unerwünscht angesehen wird.
|
||||
|
||||
**Konsequenz**: Eine private, schriftliche Verwarnung durch die Leiter der Community,
|
||||
in der die Art des Verstoßes klar dargelegt und erklärt wird, warum das
|
||||
Verhalten unangemessen war. Eine öffentliche Entschuldigung kann verlangt werden.
|
||||
|
||||
### 2. Warnung
|
||||
|
||||
**Auswirkungen auf die Community**: Ein Verstoß durch einen einzelnen Vorfall
|
||||
oder eine Reihe von Handlungen.
|
||||
|
||||
**Konsequenz**: Eine Verwarnung mit Konsequenzen für das weitere Verhalten. Keine
|
||||
Interaktion mit den beteiligten Personen, einschließlich unaufgeforderter Interaktion mit
|
||||
denjenigen, die den Verhaltenskodex durchsetzen, für einen bestimmten Zeitraum. Dies
|
||||
schließt die Vermeidung von Interaktionen in Gemeinschaftsräumen sowie externen Kanälen
|
||||
wie sozialen Medien ein. Ein Verstoß gegen diese Bedingungen kann zu einer vorübergehenden oder
|
||||
dauerhaften Sperrung führen.
|
||||
|
||||
### 3. Temporärer Sperrung
|
||||
|
||||
|
||||
**Auswirkungen auf die Community**: Ein schwerwiegender Verstoß gegen die Community-Standards,
|
||||
einschließlich anhaltend unangemessenem Verhalten.
|
||||
|
||||
**Konsequenz**: Eine vorübergehende Sperrung jeglicher Art von Interaktion oder öffentlicher
|
||||
Kommunikation mit der Community für einen bestimmten Zeitraum. Während dieses Zeitraums sind
|
||||
keine öffentlichen oder privaten Interaktionen mit den betroffenen Personen,
|
||||
einschließlich unaufgeforderter Interaktionen mit denjenigen,
|
||||
die den Verhaltenskodex durchsetzen, erlaubt.
|
||||
Ein Verstoß gegen diese Bedingungen kann zu einer dauerhaften Sperrung führen.
|
||||
|
||||
### 4. Dauerhafte Sperrung
|
||||
|
||||
**Auswirkungen auf die Community**: Wiederholte Verstöße gegen die Community-Standards,
|
||||
einschließlich anhaltend unangemessenem Verhalten, Belästigung einer
|
||||
Person oder Aggression gegenüber oder Herabwürdigung von Personengruppen.
|
||||
|
||||
**Konsequenz**: Ein dauerhafter Ausschluss von jeglicher öffentlicher
|
||||
Interaktion innerhalb der Community.
|
||||
|
||||
## Quellenangabe
|
||||
|
||||
Dieser Verhaltenskodex ist eine Adaption des [Contributor Covenant][homepage],
|
||||
Version 2.0, verfügbar unter
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Die Richtlinien zu den Auswirkungen auf die Gemeinschaft wurden inspiriert von
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
Für Antworten auf häufig gestellte Fragen zu diesem Verhaltenskodex siehe die
|
||||
häufig gestellten Fragen (FAQ) unter
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Übersetzungen sind verfügbar
|
||||
unter [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
|
||||
133
docs/CODE_OF_CONDUCT-KR.md
Normal file
133
docs/CODE_OF_CONDUCT-KR.md
Normal file
@@ -0,0 +1,133 @@
|
||||
|
||||
# 기여자 계약 행동 강령
|
||||
|
||||
## 우리의 서약
|
||||
|
||||
회원, 기여자, 리더로서 우리는 나이, 신체 크기, 눈에
|
||||
보이거나 보이지 않는 장애, 민족, 성 특성, 성 정체성 및
|
||||
표현, 경험 수준, 교육, 사회 경제적 지위, 국적, 외모,
|
||||
인종, 종교, 성적 정체성 및 지향에 관계없이 모든 사람이
|
||||
괴롭힘 없이 커뮤니티에 참여할 수 있도록 할 것을
|
||||
서약합니다.
|
||||
|
||||
우리는 개방적이고 환영하며 다양하고 포용적이며 건강한 커뮤니티에
|
||||
기여하는 방식으로 행동하고 교류할 것을 약속합니다.
|
||||
|
||||
## 우리의 표준
|
||||
|
||||
커뮤니티의 긍정적인 환경에 기여하는 행동의 예는
|
||||
다음과 같습니다:
|
||||
|
||||
* 다른 사람들에게 공감과 친절을 보여주기
|
||||
* 다양한 의견, 관점, 경험을 존중하기
|
||||
* 건설적인 피드백을 제공하고 우아하게 받아들이기
|
||||
* 우리의 실수로 인해 영향을 받은 사람들에게 책임을 받아들이고 사과하며
|
||||
그 경험을 통해 배우기
|
||||
* 우리 개인뿐만 아니라 전체 커뮤니티에 가장 좋은 것이 무엇인지
|
||||
집중하기
|
||||
|
||||
용납할 수 없는 행동의 예는 다음과 같습니다:
|
||||
|
||||
* 성적인 언어 또는 이미지의 사용, 모든 종류의 성적 관심 또는
|
||||
접근 행위
|
||||
* 트롤링, 모욕적이거나 경멸적인 댓글, 개인적 또는 정치적 공격
|
||||
* 공개적 또는 사적인 괴롭힘
|
||||
* 명시적인 허가 없이 타인의 실제 주소 또는 이메일 주소와 같은
|
||||
개인정보를 게시하는 행위
|
||||
* 직업적 환경에서 합리적으로 부적절하다고 간주될 수 있는
|
||||
기타 행위
|
||||
|
||||
## 시행 책임
|
||||
|
||||
커뮤니티 리더는 허용되는 행동의 기준을 명확히 하고 시행할
|
||||
책임이 있으며 부적절하거나 위협적이거나 모욕적이거나
|
||||
유해하다고 판단되는 행동에 대해 적절하고 공정한 시정 조치를
|
||||
취합니다.
|
||||
|
||||
커뮤니티 리더는 본 행동 강령에 부합하지 않는 댓글, 커밋,
|
||||
코드, 위키 편집, 이슈 및 기타 기여를 삭제, 편집 또는 거부할
|
||||
권한과 책임이 있으며, 적절한 경우 중재 결정의 이유를
|
||||
전달합니다.
|
||||
|
||||
## 범위
|
||||
|
||||
본 행동 강령은 모든 커뮤니티 공간에서 적용되며, 개인이 공개
|
||||
공간에서 커뮤니티를 공식적으로 대표하는 경우에도 적용됩니다.
|
||||
커뮤니티를 대표하는 예로는 공식 이메일 주소 사용, 공식 소셜 미디어
|
||||
계정을 통한 게시, 온라인 또는 오프라인 이벤트에서 지정된 대표자로
|
||||
활동하는 것 등이 있습니다.
|
||||
|
||||
## 시행
|
||||
|
||||
모욕적, 괴롭힘 또는 기타 용납할 수 없는 행동은
|
||||
[info@rustdesk.com](mailto:info@rustdesk.com)으로 법 집행을 담당하는 커뮤니티 리더에게
|
||||
신고하실 수 있습니다.
|
||||
모든 불만 사항은 신속하고 공정하게 검토 및 조사됩니다.
|
||||
|
||||
모든 커뮤니티 리더는 모든 사건 신고자의 사생활과 보안을 존중할 의무가
|
||||
있습니다.
|
||||
|
||||
## 시행 지침
|
||||
|
||||
커뮤니티 리더는 이 행동 강령을 위반한 것으로 간주되는 모든 행동에 대한
|
||||
결과를 결정할 때 다음 커뮤니티 영향 지침을 따릅니다:
|
||||
|
||||
### 1. 수정
|
||||
|
||||
**커뮤니티 영향**: 커뮤니티에서 비전문적이거나 환영받지 못하는
|
||||
것으로 간주되는 부적절한 언어 사용이나 기타 행위입니다.
|
||||
|
||||
**결과**: 커뮤니티 리더의 비공개 서면 경고. 위반 사항의 성격과
|
||||
해당 행동이 부적절했던 이유를 명확히 설명해야 합니다.
|
||||
공개 사과를 요청할 수도 있습니다.
|
||||
|
||||
### 2. 경고
|
||||
|
||||
**커뮤니티 영향**: 단일 사건 또는 일련의 행위를 통한
|
||||
위반입니다.
|
||||
|
||||
**결과**: 지속적인 행동에 대한 경고 및 결과. 행동 강령 시행 담당자와의
|
||||
원치 않는 상호작용을 포함하여 관련자와의 상호작용은 일정
|
||||
기간 동안 금지됩니다. 여기에는 공동 공간 및 소셜 미디어와
|
||||
같은 외부 채널에서의 상호작용 금지가 포함됩니다. 이러한
|
||||
조건을 위반할 경우 일시적 또는 영구적으로 이용이 금지될 수
|
||||
있습니다.
|
||||
|
||||
### 3. 일시 금지
|
||||
|
||||
**커뮤니티 영향**: 지속적인 부적절한 행동을 포함하여
|
||||
커뮤니티 기준을 심각하게 위반한 경우입니다.
|
||||
|
||||
**결과**: 일정 기간 동안 커뮤니티와의 모든 상호작용이나 공개적인 소통이
|
||||
일시적으로 금지됩니다. 이 기간 동안에는 행동 강령을 시행하는
|
||||
사람들과의 원치 않는 상호작용을 포함하여 관련자들과의 공개적 또는
|
||||
사적인 상호작용이 허용되지 않습니다.
|
||||
이러한 조건을 위반할 경우 영구적으로 이용이 금지될 수 있습니다.
|
||||
|
||||
### 4. 영구 금지
|
||||
|
||||
**커뮤니티 영향**: 지속적인 부적절한 행동, 특정 개인에 대한 괴롭힘,
|
||||
특정 계층에 대한 공격성 또는 비하 등 공동체 기준을 위반하는
|
||||
행동을 보이는 경우입니다.
|
||||
|
||||
**결과**: 공동체 내 모든 종류의 공개적인 상호작용이 영구적으로
|
||||
금지됩니다.
|
||||
|
||||
## 귀속
|
||||
|
||||
본 행동 강령은 [Contributor Covenant][homepage] 버전 2.0을 바탕으로 작성되었으며
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]에서
|
||||
확인하실 수 있습니다.
|
||||
|
||||
커뮤니티 영향 지침은
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC]에서 영감을 받았습니다.
|
||||
|
||||
본 행동 강령에 대한 일반적인 질문은 [https://www.contributor-covenant.org/faq][FAQ]에서 FAQ를
|
||||
참조하세요. 번역은 [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
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
[Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) :تواصل معنا عبر
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
.Rustبرنامج آخر لسطح المكتب عن بعد، مكتوب بـ
|
||||
يعمل خارج الصندوق، لا حاجة إلى إعدادات. لديك سيطرة كاملة على بياناتك، دون مخاوف بشأن الأمن. يمكنك استخدام خادم
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
Popovídejte si s námi: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Zase další software pro přístup k ploše na dálku, naprogramovaný v jazyce Rust. Funguje hned tak, jak je – není třeba žádného nastavování. Svá data máte ve svých rukách, bez obav o zabezpečení. Je možné používat námi poskytovaný propojovací/předávací (relay) server, [vytvořit si svůj vlastní](https://rustdesk.com/server), nebo [si dokonce svůj vlastní naprogramovat](https://github.com/rustdesk/rustdesk-server-demo), budete-li chtít.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Chat med os: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Endnu en fjernskrivebordssoftware, skrevet i Rust. Fungerer ud af æsken, ingen konfiguration påkrævet. Du har fuld kontrol over dine data uden bekymringer om sikkerhed. Du kan bruge vores rendezvous/relay-server, [opsætte din egen](https://rustdesk.com/server), eller [skrive din egen rendezvous/relay-server](https://github.com/rustdesk/rustdesk- server-demo).
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
Reden Sie mit uns auf: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
RustDesk ist eine in Rust geschriebene Remote-Desktop-Software, die out of the box ohne besondere Konfiguration funktioniert. Sie haben die volle Kontrolle über Ihre Daten und müssen sich keine Sorgen um die Sicherheit machen. Sie können unseren Rendezvous/Relay-Server nutzen, [einen eigenen Server aufsetzen](https://rustdesk.com/server) oder [einen eigenen Server programmieren](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Babili kun ni: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Denove alia fora labortabla programo, skribita en Rust. Ĝi funkcias elskatole, ne bezonas konfiguraĵon. Vi havas la tutan kontrolon sur viaj datumoj, sen zorgo pri sekureco. Vi povas uzi nian servilon rendezvous/relajsan, [agordi vian propran](https://rustdesk.com/server), aŭ [skribi vian propran servilon rendezvous/relajsan](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
Chatea con nosotros: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
با ما گفتگو کنید: [Reddit](https://www.reddit.com/r/rustdesk) | [Twitter](https://twitter.com/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
راستدسک (RustDesk) نرمافزاری برای کارکردن با رایانهی رومیزی از راه دور است و با زبان برنامهنویسی Rust نوشته شده است. نیاز به تنظیمات چندانی ندارد و شما را قادر می سازد تا بدون نگرانی از امنیت اطلاعات خود بر آنها کنترل کامل داشته باشید.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Juttele meidän kanssa: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Vielä yksi etätyöpöytäohjelmisto, ohjelmoitu Rust-kielellä. Toimii suoraan pakkauksesta, ei tarvitse asetusta. Hallitset täysin tietojasi, ei tarvitse murehtia turvallisuutta. Voit käyttää meidän rendezvous/relay-palvelinta, [aseta omasi](https://rustdesk.com/server), tai [kirjoittaa oma rendezvous/relay-palvelin](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Chattez avec nous : [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Encore un autre logiciel de bureau à distance, écrit en Rust. Fonctionne directement, aucune configuration n'est nécessaire. Vous avez le contrôle total de vos données, sans aucun souci de sécurité. Vous pouvez utiliser notre serveur de rendez-vous/relais, [configurer le vôtre](https://rustdesk.com/server), ou [écrire votre propre serveur de rendez-vous/relais](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Επικοινωνήστε μαζί μας μέσω: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Ένα λογισμικό απομακρυσμένης επιφάνειας εργασίας, γραμμένο σε γλώσσα Rust. Δεν χρειάζεται κάποια παραμετροποίηση, λειτουργεί αμέσως μετά την εγκατάσταση. Έχετε τον πλήρη έλεγχο των δεδομένων σας, χωρίς να ανησυχείτε για την ασφάλειά τους. Μπορείτε να χρησιμοποιήσετε τους προκαθορισμένους διακομιστές rendezvous/αναμετάδοσης, [να εγκαταστήσετε τον δικό σας διακομιστή](https://rustdesk.com/server), ή [να αναπτύξετε ένα δικό σας διακομιστή rendezvous/αναμετάδοσης](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Beszélgess velünk: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
A RustDesk egy távoli elérésű asztali szoftver, Rust-ban írva. Működik mindenféle konfiguráció nélkül, feltelepítéssel, vagy anélkül. Az adataidat teljesen te kezeled, nincs szükség aggódásra a harmadik felek miatt. Használhatod a RustDesk punblikus randevú/relay szervereit, [hostolhatsz sajátot](https://rustdesk.com/server), vagy akár [írhatsz is egyet](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Mari mengobrol bersama kami: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
[](https://console.algora.io/org/rustdesk/bounties?status=open)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Chatta con noi su: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
[](https://console.algora.io/org/rustdesk/bounties?status=open)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
私たちと話す: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Rustで書かれた、設定不要ですぐに使えるリモートデスクトップソフトウェアです。自分のデータを完全にコントロールでき、セキュリティの心配もありません。私たちのランデブー/リレーサーバを使うことも、[自分でサーバーをセットアップする](https://rustdesk.com/server) ことも、 [自分でランデブー/リレーサーバを作成する](https://github.com/rustdesk/rustdesk-server-demo)こともできます。
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||
<a href="#raw-steps-to-build">빌드</a> •
|
||||
<a href="#how-to-build-with-docker">Docker</a> •
|
||||
<a href="#file-structure">구조</a> •
|
||||
<a href="#snapshot">스냇샷</a><br>
|
||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</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>
|
||||
<a href="#빌드를 위한 원시 단계">빌드</a> •
|
||||
<a href="#Docker로 빌드하는 방법">Docker</a> •
|
||||
<a href="#파일 구조">구조</a> •
|
||||
<a href="#스크린샷">스냇샷</a><br>
|
||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</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-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>]<br>
|
||||
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
|
||||
</p>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
|
||||
우리와 채팅: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Rust로 작성된 또 다른 원격 데스크톱 소프트웨어입니다. 구성할 필요 없이 바로 사용할 수 있습니다. 보안에 대한 걱정 없이 데이터를 완벽하게 제어할 수 있습니다. 저희의 rendezvous/relay server 서버를 사용하거나, [직접 설정](https://rustdesk.com/server), 또는 [직접 rendezvous/relay 서버를 작성할 수 있습니다](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
또 하나의 원격 데스크톱 솔루션으로, Rust로 작성되었습니다. 별도의 설정 없이 바로 사용할 수 있습니다. 데이터에 대한 완전한 통제권을 가지며 보안에 대한 걱정이 없습니다. 저희 랑데부/릴레이 서버를 사용하거나, [직접 설정](https://rustdesk.com/server)하거나, [자신만의 랑데부/릴레이 서버를 작성](https://github.com/rustdesk/rustdesk-server-demo)할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
ഞങ്ങളുമായി ചാറ്റ് ചെയ്യുക: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
റസ്റ്റിൽ എഴുതിയ മറ്റൊരു റിമോട്ട് ഡെസ്ക്ടോപ്പ് സോഫ്റ്റ്വെയർ. ബോക്സിന് പുറത്ത് പ്രവർത്തിക്കുന്നു, കോൺഫിഗറേഷൻ ആവശ്യമില്ല. സുരക്ഷയെക്കുറിച്ച് ആശങ്കകളൊന്നുമില്ലാതെ, നിങ്ങളുടെ ഡാറ്റയുടെ പൂർണ്ണ നിയന്ത്രണം നിങ്ങൾക്കുണ്ട്. നിങ്ങൾക്ക് ഞങ്ങളുടെ rendezvous/relay സെർവർ ഉപയോഗിക്കാം, [സ്വന്തമായി സജ്ജീകരിക്കുക](https://rustdesk.com/server), അല്ലെങ്കിൽ [നിങ്ങളുടെ സ്വന്തം rendezvous/relay സെർവർ എഴുതുക](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Chat met ons: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Alweer een andere programma voor -bureaublad op afstand-, geschreven in Rust. Werkt -out of the box-, geen configuratie nodig. U heeft volledige controle over uw gegevens, en hoeft zich geen zorgen te maken over de beveiliging. U kunt onze rendez-vous/relay server gebruiken, [je eigen server opzetten](https://rustdesk.com/blog/id-relay-set), of [je eigen rendez-vous/relay-server schrijven](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Snakk med oss: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Kolejny program do zdalnego pulpitu, napisany w Rust. Działa od samego początku, nie wymaga konfiguracji. Masz pełną kontrolę nad swoimi danymi, bez obaw o bezpieczeństwo. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Mais um software de desktop remoto, escrito em Rust. Funciona por padrão, sem necessidade de configuração. Você tem completo controle de seus dados, sem se preocupar com segurança. Você pode usar nossos servidores de rendezvous/relay, [configurar seu próprio](https://rustdesk.com/server), ou [escrever seu próprio servidor de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
Общение с нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Ещё одно программное обеспечение для удаленного рабочего стола, написанное на Rust. Работает из коробки, настройки не требует. Вы полностью контролируете свои данные, не беспокоясь о безопасности. Вы можете использовать наш сервер ретрансляции, [настроить свой собственный](https://rustdesk.com/server), или [написать свой](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -7,34 +7,37 @@
|
||||
<a href="#file-structure">Dosya Yapısı</a> •
|
||||
<a href="#snapshot">Ekran Görüntüleri</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>]<br>
|
||||
<b>README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> ve <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Belge</a>'sini ana dilinize çevirmemiz için yardımınıza ihtiyacımız var</b>
|
||||
<b>README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> ve <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Dökümantasyonu</a>'nu ana dilinize çevirmemiz için yardımınıza ihtiyacımız var</b>
|
||||
</p>
|
||||
|
||||
|
||||
> [!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.
|
||||
|
||||
Bizimle sohbet edin: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Başka bir uzak masaüstü yazılımı daha, Rust dilinde yazılmış. Hemen kullanıma hazır, hiçbir yapılandırma gerektirmez. Verilerinizin tam kontrolünü elinizde tutarsınız ve güvenlikle ilgili endişeleriniz olmaz. Kendi buluş/iletme sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi buluş/iletme sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
Rust dilinde yazılmış, başka bir uzak masaüstü yazılımı daha. Hiçbir yapılandırma gerekmeksizin, hemen kullanıma hazır. Güvenlik konusunda hiçbir endişe duymadan, verileriniz üzerinde tam kontrole sahip olun. Kendi rendezvous/relay sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi rendezvous/relay sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||

|
||||
|
||||
RustDesk, herkesten katkıyı kabul eder. Başlamak için [CONTRIBUTING.md](CONTRIBUTING-TR.md) belgesine göz atın.
|
||||
RustDesk, herkesin katkısına açıktır. Başlamak için [CONTRIBUTING.md](CONTRIBUTING-TR.md) belgesine göz atın.
|
||||
|
||||
[**SSS**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||
|
||||
[**BİNARİ İNDİR**](https://github.com/rustdesk/rustdesk/releases)
|
||||
[**BINARY İNDİR**](https://github.com/rustdesk/rustdesk/releases)
|
||||
|
||||
[**NİGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
[**NIGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="F-Droid'de Alın"
|
||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||
|
||||
## Bağımlılıklar
|
||||
## Gereksinimler
|
||||
|
||||
Masaüstü sürümleri GUI için
|
||||
|
||||
[Sciter](https://sciter.com/) veya Flutter kullanır, bu kılavuz sadece Sciter içindir.
|
||||
Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Flutter sürümünü derlemek için [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)'ımıza bakın.
|
||||
|
||||
Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
||||
|
||||
@@ -46,7 +49,7 @@ Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
||||
|
||||
- Rust geliştirme ortamınızı ve C++ derleme ortamınızı hazırlayın.
|
||||
|
||||
- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` çevresel değişkenini doğru bir şekilde ayarlayın.
|
||||
- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` ortam değişkenini doğru bir şekilde ayarlayın.
|
||||
|
||||
- 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
|
||||
@@ -123,7 +126,7 @@ VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||
|
||||
## Docker ile Derleme Nasıl Yapılır
|
||||
|
||||
Öncelikle deposunu klonlayın ve Docker konteynerini oluşturun:
|
||||
Önce repository'i klonlayın ve Docker container'ını oluşturun.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
@@ -131,44 +134,40 @@ cd rustdesk
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
Ardından, uygulamayı derlemek için her seferinde aşağıdaki komutu çalıştırın:
|
||||
Ardından, uygulamayı her derlemeniz gerektiğinde aşağıdaki komutu çalıştırın:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
İlk derleme, bağımlılıklar önbelleğe alınmadan önce daha uzun sürebilir, sonraki derlemeler daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu
|
||||
|
||||
komutun sonunda `<İSTEĞE BAĞLI-ARGÜMANLAR>` pozisyonunda yapabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan yürütülebilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir:
|
||||
Bilin ki ilk derlemeniz gereksinimlerin önbelleği yüklenmesinden ötürü uzun sürebilir, sonraki derlemeleriniz daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu komutun sonunda ki `<OPTIONAL-ARGS>` yerine yazabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan çalıştırılabilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir olacaktır:
|
||||
|
||||
```sh
|
||||
target/debug/rustdesk
|
||||
```
|
||||
|
||||
Veya, yayın yürütülebilir dosyası çalıştırılıyorsa:
|
||||
Veya, yayım çalıştırılabilir dosyası için:
|
||||
|
||||
```sh
|
||||
target/release/rustdesk
|
||||
```
|
||||
|
||||
Lütfen bu komutları RustDesk deposunun kökünden çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır ve ana makinede değil.
|
||||
Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır, ana makinede değil.
|
||||
|
||||
## Dosya Yapısı
|
||||
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video kodlayıcı, yapılandırma, tcp/udp sarmalayıcı, protobuf, dosya transferi için fs işlevleri ve diğer bazı yardımcı işlevler
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, protobuf, dosya transferi için fs fonksiyonları ve diğer bazı yardımcı işlevler
|
||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: ekran yakalama
|
||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platforma özgü klavye/fare kontrolü
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pasta/klavye/video hizmetleri ve ağ bağlantıları
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: bir eş bağlantısı başlatır
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişim kurar, uzak doğrudan (TCP delik vurma) veya iletme bağlantısını bekler
|
||||
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: platforma özgü kopyala/yapıştır implementasyonları.
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: Eski Sciter UI (kaldırılacak)
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pano/input/video servisleri ve ağ bağlantıları
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Eşli bağlantı başlat
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: mobil için Flutter kodu
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter web istemcisi için JavaScript
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript
|
||||
|
||||
> [!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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Спілкування з нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Ще один застосунок для віддаленого керування стільницею, написаний на Rust. Працює з коробки, не потребує налаштування. Ви повністю контролюєте свої дані, не турбуючись про безпеку. Ви можете використовувати наш сервер ретрансляції, [налаштувати свій власний](https://rustdesk.com/server), або [написати свій власний сервер ретрансляції](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
Hãy trao đổi với chúng tôi qua: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
RustDesk là một phần mềm điểu khiển máy tính từ xa mã nguồn mở, được viết bằng Rust. Nó hoạt động ngay sau khi cài đặt, không yêu cầu cấu hình phức tạp. Bạn có toàn quyền kiểm soát với dữ liệu của mình mà không cần phải lo lắng về vấn đề bảo mật. Bạn có thể sử dụng máy chủ rendezvous/relay của chúng tôi hoặc [tự cài đặt máy chủ của riêng mình](https://rustdesk.com/server) hay thậm chí [tự tạo máy chủ rendezvous/relay cho riêng bạn](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
与我们交流: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
远程桌面软件,开箱即用,无需任何配置。您完全掌控数据,不用担心安全问题。您可以使用我们的注册/中继服务器,
|
||||
或者[自己设置](https://rustdesk.com/server),
|
||||
|
||||
@@ -13,11 +13,13 @@ import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get/get_rx/src/rx_workers/utils/debouncer.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uni_links/uni_links.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -42,6 +44,7 @@ import 'package:flutter_hbb/native/win32.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
|
||||
import 'package:flutter_hbb/native/common.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final globalKey = GlobalKey<NavigatorState>();
|
||||
final navigationBarKey = GlobalKey();
|
||||
@@ -74,6 +77,9 @@ bool _ignoreDevicePixelRatio = true;
|
||||
int windowsBuildNumber = 0;
|
||||
DesktopType? desktopType;
|
||||
|
||||
// Tolerance used for floating-point position comparisons to avoid precision errors.
|
||||
const double _kPositionEpsilon = 1e-6;
|
||||
|
||||
bool get isMainDesktopWindow =>
|
||||
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
||||
|
||||
@@ -105,6 +111,10 @@ enum DesktopType {
|
||||
portForward,
|
||||
}
|
||||
|
||||
bool isDoubleEqual(double a, double b) {
|
||||
return (a - b).abs() < _kPositionEpsilon;
|
||||
}
|
||||
|
||||
class IconFont {
|
||||
static const _family1 = 'Tabbar';
|
||||
static const _family2 = 'PeerSearchbar';
|
||||
@@ -1621,7 +1631,8 @@ bool mainGetPeerBoolOptionSync(String id, String key) {
|
||||
// Use `sessionGetToggleOption()` and `sessionToggleOption()` instead.
|
||||
// Because all session options use `Y` and `<Empty>` as values.
|
||||
|
||||
Future<bool> matchPeer(String searchText, Peer peer) async {
|
||||
Future<bool> matchPeer(
|
||||
String searchText, Peer peer, PeerTabIndex peerTabIndex) async {
|
||||
if (searchText.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
@@ -1632,11 +1643,14 @@ Future<bool> matchPeer(String searchText, Peer peer) async {
|
||||
peer.username.toLowerCase().contains(searchText)) {
|
||||
return true;
|
||||
}
|
||||
final alias = peer.alias;
|
||||
if (alias.isEmpty) {
|
||||
return false;
|
||||
if (peer.alias.toLowerCase().contains(searchText)) {
|
||||
return true;
|
||||
}
|
||||
return alias.toLowerCase().contains(searchText);
|
||||
if (peerTabShowNote(peerTabIndex) &&
|
||||
peer.note.toLowerCase().contains(searchText)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get the image for the current [platform].
|
||||
@@ -1666,6 +1680,16 @@ class LastWindowPosition {
|
||||
LastWindowPosition(this.width, this.height, this.offsetWidth,
|
||||
this.offsetHeight, this.isMaximized, this.isFullscreen);
|
||||
|
||||
bool equals(LastWindowPosition other) {
|
||||
return (
|
||||
(width == other.width) &&
|
||||
(height == other.height) &&
|
||||
(offsetWidth == other.offsetWidth) &&
|
||||
(offsetHeight == other.offsetHeight) &&
|
||||
(isMaximized == other.isMaximized) &&
|
||||
(isFullscreen == other.isFullscreen));
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
"width": width,
|
||||
@@ -1705,9 +1729,14 @@ String get windowFramePrefix =>
|
||||
? "incoming_"
|
||||
: (bind.isOutgoingOnly() ? "outgoing_" : ""));
|
||||
|
||||
typedef WindowKey = ({WindowType type, int? windowId});
|
||||
|
||||
LastWindowPosition? _lastWindowPosition = null;
|
||||
final Debouncer _saveWindowDebounce = Debouncer(delay: Duration(seconds: 1));
|
||||
|
||||
/// Save window position and size on exit
|
||||
/// Note that windowId must be provided if it's subwindow
|
||||
Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
||||
Future<void> saveWindowPosition(WindowType type, {int? windowId, bool? flush}) async {
|
||||
if (type != WindowType.Main && windowId == null) {
|
||||
debugPrint(
|
||||
"Error: windowId cannot be null when saving positions for sub window");
|
||||
@@ -1776,16 +1805,40 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
||||
|
||||
final pos = LastWindowPosition(
|
||||
sz.width, sz.height, position.dx, position.dy, isMaximized, isFullscreen);
|
||||
debugPrint(
|
||||
"Saving frame: $windowId: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}");
|
||||
|
||||
await bind.setLocalFlutterOption(
|
||||
k: windowFramePrefix + type.name, v: pos.toString());
|
||||
final WindowKey key = (type: type, windowId: windowId);
|
||||
|
||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
||||
windowId != null) {
|
||||
await _saveSessionWindowPosition(
|
||||
type, windowId, isMaximized, isFullscreen, pos);
|
||||
final bool haveNewWindowPosition = (_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!);
|
||||
final bool isPreviousNewWindowPositionPending = _saveWindowDebounce.isRunning;
|
||||
|
||||
if (haveNewWindowPosition || isPreviousNewWindowPositionPending) {
|
||||
_lastWindowPosition = pos;
|
||||
|
||||
if (flush ?? false) {
|
||||
// If a previous update is pending, replace it.
|
||||
_saveWindowDebounce.cancel();
|
||||
await _saveWindowPositionActual(key);
|
||||
} else if (haveNewWindowPosition) {
|
||||
_saveWindowDebounce.call(() => _saveWindowPositionActual(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveWindowPositionActual(WindowKey key) async {
|
||||
LastWindowPosition? pos = _lastWindowPosition;
|
||||
|
||||
if (pos != null) {
|
||||
debugPrint(
|
||||
"Saving frame: ${key.windowId}: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}");
|
||||
|
||||
await bind.setLocalFlutterOption(
|
||||
k: windowFramePrefix + key.type.name, v: pos.toString());
|
||||
|
||||
if ((key.type == WindowType.RemoteDesktop || key.type == WindowType.ViewCamera) &&
|
||||
key.windowId != null) {
|
||||
await _saveSessionWindowPosition(
|
||||
key.type, key.windowId!, pos.isMaximized ?? false, pos.isFullscreen ?? false, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,6 +1904,8 @@ Future<Size> _adjustRestoreMainWindowSize(double? width, double? height) async {
|
||||
return Size(restoreWidth, restoreHeight);
|
||||
}
|
||||
|
||||
// Consider using Rect.contains() instead,
|
||||
// though the implementation is not exactly the same.
|
||||
bool isPointInRect(Offset point, Rect rect) {
|
||||
return point.dx >= rect.left &&
|
||||
point.dx <= rect.right &&
|
||||
@@ -1948,8 +2003,24 @@ Future<bool> restoreWindowPosition(WindowType type,
|
||||
|
||||
var lpos = LastWindowPosition.loadFromString(pos);
|
||||
if (lpos == null) {
|
||||
debugPrint("no window position saved, ignoring position restoration");
|
||||
return false;
|
||||
debugPrint("No window position saved, trying to center the window.");
|
||||
switch (type) {
|
||||
case WindowType.Main:
|
||||
// Center the main window only if no position is saved (on first run).
|
||||
if (isWindows || isLinux) {
|
||||
await windowManager.center();
|
||||
}
|
||||
// For MacOS, the window is already centered by default.
|
||||
// See https://github.com/rustdesk/rustdesk/blob/9b9276e7524523d7f667fefcd0694d981443df0e/flutter/macos/Runner/Base.lproj/MainMenu.xib#L333
|
||||
// If `<windowPositionMask>` in `<window>` is not set, the window will be centered.
|
||||
break;
|
||||
default:
|
||||
// No need to change the position of a sub window if no position is saved,
|
||||
// since the default position is already centered.
|
||||
// https://github.com/rustdesk/rustdesk/blob/317639169359936f7f9f85ef445ec9774218772d/flutter/lib/utils/multi_window_manager.dart#L163
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
||||
if (!isRemotePeerPos && windowId != null) {
|
||||
@@ -2753,7 +2824,7 @@ class ServerConfig {
|
||||
} catch (err) {
|
||||
final input = msg.split('').reversed.join('');
|
||||
final bytes = base64Decode(base64.normalize(input));
|
||||
json = jsonDecode(utf8.decode(bytes));
|
||||
json = jsonDecode(utf8.decode(bytes, allowMalformed: true));
|
||||
}
|
||||
idServer = json['host'] ?? '';
|
||||
relayServer = json['relay'] ?? '';
|
||||
@@ -3910,3 +3981,39 @@ String get appName {
|
||||
}
|
||||
return _appName;
|
||||
}
|
||||
|
||||
String getConnectionText(bool secure, bool direct, String streamType) {
|
||||
String connectionText;
|
||||
if (secure && direct) {
|
||||
connectionText = translate("Direct and encrypted connection");
|
||||
} else if (secure && !direct) {
|
||||
connectionText = translate("Relayed and encrypted connection");
|
||||
} else if (!secure && direct) {
|
||||
connectionText = translate("Direct and unencrypted connection");
|
||||
} else {
|
||||
connectionText = translate("Relayed and unencrypted connection");
|
||||
}
|
||||
if (streamType == 'Relay') {
|
||||
streamType = 'TCP';
|
||||
}
|
||||
if (streamType.isEmpty) {
|
||||
return connectionText;
|
||||
} else {
|
||||
return '$connectionText ($streamType)';
|
||||
}
|
||||
}
|
||||
|
||||
String decode_http_response(http.Response resp) {
|
||||
try {
|
||||
// https://github.com/rustdesk/rustdesk-server-pro/discussions/758
|
||||
return utf8.decode(resp.bodyBytes, allowMalformed: true);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to decode response as UTF-8: $e');
|
||||
// Fallback to bodyString which handles encoding automatically
|
||||
return resp.body;
|
||||
}
|
||||
}
|
||||
|
||||
bool peerTabShowNote(PeerTabIndex peerTabIndex) {
|
||||
return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ class PeerPayload {
|
||||
"platform": _platform(p.info['os']),
|
||||
"hostname": p.info['device_name'],
|
||||
"device_group_name": p.device_group_name,
|
||||
"note": p.note,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,15 +249,17 @@ class AbProfile {
|
||||
String name;
|
||||
String owner;
|
||||
String? note;
|
||||
dynamic info;
|
||||
int rule;
|
||||
|
||||
AbProfile(this.guid, this.name, this.owner, this.note, this.rule);
|
||||
AbProfile(this.guid, this.name, this.owner, this.note, this.rule, this.info);
|
||||
|
||||
AbProfile.fromJson(Map<String, dynamic> json)
|
||||
: guid = json['guid'] ?? '',
|
||||
name = json['name'] ?? '',
|
||||
owner = json['owner'] ?? '',
|
||||
note = json['note'] ?? '',
|
||||
info = json['info'],
|
||||
rule = json['rule'] ?? 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,9 +77,11 @@ class CurrentDisplayState {
|
||||
class ConnectionType {
|
||||
final Rx<String> _secure = kInvalidValueStr.obs;
|
||||
final Rx<String> _direct = kInvalidValueStr.obs;
|
||||
final Rx<String> _stream_type = kInvalidValueStr.obs;
|
||||
|
||||
Rx<String> get secure => _secure;
|
||||
Rx<String> get direct => _direct;
|
||||
Rx<String> get stream_type => _stream_type;
|
||||
|
||||
static String get strSecure => 'secure';
|
||||
static String get strInsecure => 'insecure';
|
||||
@@ -94,9 +96,14 @@ class ConnectionType {
|
||||
_direct.value = v ? strDirect : strIndirect;
|
||||
}
|
||||
|
||||
void setStreamType(String v) {
|
||||
_stream_type.value = v;
|
||||
}
|
||||
|
||||
bool isValid() {
|
||||
return _secure.value != kInvalidValueStr &&
|
||||
_direct.value != kInvalidValueStr;
|
||||
_direct.value != kInvalidValueStr &&
|
||||
_stream_type.value != kInvalidValueStr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -466,6 +466,7 @@ class _AddressBookState extends State<AddressBook> {
|
||||
IDTextEditingController idController = IDTextEditingController(text: '');
|
||||
TextEditingController aliasController = TextEditingController(text: '');
|
||||
TextEditingController passwordController = TextEditingController(text: '');
|
||||
TextEditingController noteController = TextEditingController(text: '');
|
||||
final tags = List.of(gFFI.abModel.currentAbTags);
|
||||
var selectedTag = List<dynamic>.empty(growable: true).obs;
|
||||
final style = TextStyle(fontSize: 14.0);
|
||||
@@ -494,7 +495,11 @@ class _AddressBookState extends State<AddressBook> {
|
||||
password = passwordController.text;
|
||||
}
|
||||
String? errMsg2 = await gFFI.abModel.addIdToCurrent(
|
||||
id, aliasController.text.trim(), password, selectedTag);
|
||||
id,
|
||||
aliasController.text.trim(),
|
||||
password,
|
||||
selectedTag,
|
||||
noteController.text);
|
||||
if (errMsg2 != null) {
|
||||
setState(() {
|
||||
isInProgress = false;
|
||||
@@ -600,6 +605,24 @@ class _AddressBookState extends State<AddressBook> {
|
||||
),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
)),
|
||||
row(
|
||||
label: Text(
|
||||
translate('Note'),
|
||||
style: style,
|
||||
),
|
||||
input: Obx(
|
||||
() => TextField(
|
||||
controller: noteController,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
maxLength: 300,
|
||||
decoration: InputDecoration(
|
||||
labelText: stateGlobal.isPortrait.isFalse
|
||||
? null
|
||||
: translate('Note'),
|
||||
),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
)),
|
||||
if (gFFI.abModel.currentAbTags.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
|
||||
@@ -1783,6 +1783,49 @@ void editAbTagDialog(
|
||||
});
|
||||
}
|
||||
|
||||
void editAbPeerNoteDialog(String id) {
|
||||
var isInProgress = false;
|
||||
final currentNote = gFFI.abModel.getPeerNote(id);
|
||||
var controller = TextEditingController(text: currentNote);
|
||||
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
submit() async {
|
||||
setState(() {
|
||||
isInProgress = true;
|
||||
});
|
||||
await gFFI.abModel.changeNote(id: id, note: controller.text);
|
||||
close();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Edit note")),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
maxLength: 300,
|
||||
decoration: InputDecoration(
|
||||
labelText: translate('Note'),
|
||||
),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("OK", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void renameDialog(
|
||||
{required String oldName,
|
||||
FormFieldValidator<String>? validator,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
|
||||
enum GestureState {
|
||||
none,
|
||||
@@ -96,6 +97,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
||||
if (onTwoFingerScaleEnd != null) {
|
||||
onTwoFingerScaleEnd!(d);
|
||||
}
|
||||
if (isSpecialHoldDragActive) {
|
||||
// If we are in special drag mode, we need to reset the state.
|
||||
// Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`.
|
||||
_currentState = GestureState.none;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case GestureState.threeFingerVerticalDrag:
|
||||
debugPrint("ThreeFingerState.vertical onEnd");
|
||||
|
||||
@@ -127,6 +127,10 @@ class _PeerCardState extends State<_PeerCard>
|
||||
);
|
||||
}
|
||||
|
||||
bool _showNote(Peer peer) {
|
||||
return peerTabShowNote(widget.tab) && peer.note.isNotEmpty;
|
||||
}
|
||||
|
||||
makeChild(bool isPortrait, Peer peer) {
|
||||
final name = hideUsernameOnCard == true
|
||||
? peer.hostname
|
||||
@@ -134,6 +138,8 @@ class _PeerCardState extends State<_PeerCard>
|
||||
final greyStyle = TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
|
||||
final showNote = _showNote(peer);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
@@ -185,14 +191,44 @@ class _PeerCardState extends State<_PeerCard>
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
)),
|
||||
]).marginOnly(top: isPortrait ? 0 : 2),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
name,
|
||||
style: isPortrait ? null : greyStyle,
|
||||
textAlign: TextAlign.start,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: name,
|
||||
waitDuration: const Duration(seconds: 1),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
name,
|
||||
style: isPortrait ? null : greyStyle,
|
||||
textAlign: TextAlign.start,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showNote)
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: peer.note,
|
||||
waitDuration: const Duration(seconds: 1),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
peer.note,
|
||||
style: isPortrait ? null : greyStyle,
|
||||
textAlign: TextAlign.start,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).marginOnly(
|
||||
left: peerCardUiType.value ==
|
||||
PeerUiType.list
|
||||
? 32
|
||||
: 4),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
).marginOnly(top: 2),
|
||||
@@ -278,7 +314,7 @@ class _PeerCardState extends State<_PeerCard>
|
||||
padding: const EdgeInsets.all(6),
|
||||
child:
|
||||
getPlatformImage(peer.platform, size: 60),
|
||||
).marginOnly(top: 4),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -297,8 +333,26 @@ class _PeerCardState extends State<_PeerCard>
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_showNote(peer))
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: peer.note,
|
||||
waitDuration: const Duration(seconds: 1),
|
||||
child: Text(
|
||||
peer.note,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 10),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
],
|
||||
),
|
||||
],
|
||||
).paddingAll(4.0),
|
||||
).paddingOnly(top: 4.0, left: 4.0, right: 4.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1134,6 +1188,7 @@ class AddressBookPeerCard extends BasePeerCard {
|
||||
if (gFFI.abModel.currentAbTags.isNotEmpty) {
|
||||
menuItems.add(_editTagAction(peer.id));
|
||||
}
|
||||
menuItems.add(_editNoteAction(peer.id));
|
||||
}
|
||||
final addressbooks = gFFI.abModel.addressBooksCanWrite();
|
||||
if (gFFI.peerTabModel.currentTab == PeerTabIndex.ab.index) {
|
||||
@@ -1173,6 +1228,21 @@ class AddressBookPeerCard extends BasePeerCard {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> _editNoteAction(String id) {
|
||||
return MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Edit note'),
|
||||
style: style,
|
||||
),
|
||||
proc: () {
|
||||
editAbPeerNoteDialog(id);
|
||||
},
|
||||
padding: super.menuPadding,
|
||||
dismissOnClicked: true,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
@override
|
||||
Future<String> _getAlias(String id) async =>
|
||||
@@ -1491,6 +1561,13 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
|
||||
password = peer.password;
|
||||
isSharedPassword = true;
|
||||
}
|
||||
if (password.isEmpty) {
|
||||
final abPassword = gFFI.abModel.getdefaultSharedPassword();
|
||||
if (abPassword != null) {
|
||||
password = abPassword;
|
||||
isSharedPassword = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
connect(context, peer.id,
|
||||
|
||||
@@ -71,10 +71,12 @@ class _PeersView extends StatefulWidget {
|
||||
final Peers peers;
|
||||
final PeerFilter? peerFilter;
|
||||
final PeerCardBuilder peerCardBuilder;
|
||||
final PeerTabIndex peerTabIndex;
|
||||
|
||||
const _PeersView(
|
||||
{required this.peers,
|
||||
required this.peerCardBuilder,
|
||||
required this.peerTabIndex,
|
||||
this.peerFilter,
|
||||
Key? key})
|
||||
: super(key: key);
|
||||
@@ -395,8 +397,8 @@ class _PeersViewState extends State<_PeersView>
|
||||
return peers;
|
||||
}
|
||||
searchText = searchText.toLowerCase();
|
||||
final matches =
|
||||
await Future.wait(peers.map((peer) => matchPeer(searchText, peer)));
|
||||
final matches = await Future.wait(
|
||||
peers.map((peer) => matchPeer(searchText, peer, widget.peerTabIndex)));
|
||||
final filteredList = List<Peer>.empty(growable: true);
|
||||
for (var i = 0; i < peers.length; i++) {
|
||||
if (matches[i]) {
|
||||
@@ -441,7 +443,10 @@ abstract class BasePeersView extends StatelessWidget {
|
||||
break;
|
||||
}
|
||||
return _PeersView(
|
||||
peers: peers, peerFilter: peerFilter, peerCardBuilder: peerCardBuilder);
|
||||
peers: peers,
|
||||
peerFilter: peerFilter,
|
||||
peerCardBuilder: peerCardBuilder,
|
||||
peerTabIndex: peerTabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ class RawKeyFocusScope extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// For virtual mouse when using the mouse mode on mobile.
|
||||
// Special hold-drag mode: one finger holds a button (left/right button), another finger pans.
|
||||
// This flag is to override the scale gesture to a pan gesture.
|
||||
bool isSpecialHoldDragActive = false;
|
||||
// Cache the last focal point to calculate deltas in special hold-drag mode.
|
||||
Offset _lastSpecialHoldDragFocalPoint = Offset.zero;
|
||||
|
||||
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
||||
final Widget child;
|
||||
final FFI ffi;
|
||||
@@ -97,6 +104,10 @@ class _RawTouchGestureDetectorRegionState
|
||||
bool _touchModePanStarted = false;
|
||||
Offset _doubleFinerTapPosition = Offset.zero;
|
||||
|
||||
// For mouse mode, we need to block the events when the cursor is in a blocked area.
|
||||
// So we need to cache the last tap down position.
|
||||
Offset? _lastTapDownPositionForMouseMode;
|
||||
|
||||
FFI get ffi => widget.ffi;
|
||||
FfiModel get ffiModel => widget.ffiModel;
|
||||
InputModel get inputModel => widget.inputModel;
|
||||
@@ -112,7 +123,15 @@ class _RawTouchGestureDetectorRegionState
|
||||
}
|
||||
|
||||
bool isNotTouchBasedDevice() {
|
||||
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
|
||||
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
|
||||
}
|
||||
|
||||
// Mobile, mouse mode.
|
||||
// Check if should block the mouse tap event (`_lastTapDownPositionForMouseMode`).
|
||||
bool shouldBlockMouseModeEvent() {
|
||||
return _lastTapDownPositionForMouseMode != null &&
|
||||
ffi.cursorModel.shouldBlock(_lastTapDownPositionForMouseMode!.dx,
|
||||
_lastTapDownPositionForMouseMode!.dy);
|
||||
}
|
||||
|
||||
onTapDown(TapDownDetails d) async {
|
||||
@@ -124,6 +143,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
_lastPosOfDoubleTapDown = d.localPosition;
|
||||
// Desktop or mobile "Touch mode"
|
||||
_lastTapDownDetails = d;
|
||||
} else {
|
||||
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +171,11 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
|
||||
// Using `_lastTapDownPositionForMouseMode` instead.
|
||||
if (shouldBlockMouseModeEvent()) {
|
||||
return;
|
||||
}
|
||||
// Mobile, "Mouse mode"
|
||||
await inputModel.tap(MouseButtons.left);
|
||||
}
|
||||
@@ -163,6 +189,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (handleTouch) {
|
||||
_lastPosOfDoubleTapDown = d.localPosition;
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
} else {
|
||||
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +205,12 @@ class _RawTouchGestureDetectorRegionState
|
||||
!ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) {
|
||||
return;
|
||||
}
|
||||
// Check if the position is in a blocked area when using the mouse mode.
|
||||
if (!handleTouch) {
|
||||
if (shouldBlockMouseModeEvent()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await inputModel.tap(MouseButtons.left);
|
||||
await inputModel.tap(MouseButtons.left);
|
||||
}
|
||||
@@ -198,6 +232,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
await inputModel.tapDown(MouseButtons.left);
|
||||
}
|
||||
} else {
|
||||
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +258,10 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (!isMoved) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (shouldBlockMouseModeEvent()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await inputModel.tap(MouseButtons.right);
|
||||
} else {
|
||||
@@ -274,6 +314,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
if (isSpecialHoldDragActive) return;
|
||||
await inputModel.sendMouse('down', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +324,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
if (isSpecialHoldDragActive) return;
|
||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||
}
|
||||
}
|
||||
@@ -377,12 +419,26 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (isSpecialHoldDragActive) {
|
||||
// Initialize the last focal point to calculate deltas manually.
|
||||
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||
}
|
||||
}
|
||||
|
||||
onTwoFingerScaleUpdate(ScaleUpdateDetails d) async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If in special drag mode, perform a pan instead of a scale.
|
||||
if (isSpecialHoldDragActive) {
|
||||
// Calculate delta manually to avoid the jumpy behavior.
|
||||
final delta = d.focalPoint - _lastSpecialHoldDragFocalPoint;
|
||||
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||
await ffi.cursorModel.updatePan(delta * 2.0, d.focalPoint, handleTouch);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
final scale = ((d.scale - _scale) * 1000).toInt();
|
||||
_scale = d.scale;
|
||||
@@ -420,7 +476,9 @@ class _RawTouchGestureDetectorRegionState
|
||||
// No idea why we need to set the view style to "" here.
|
||||
// bind.sessionSetViewStyle(sessionId: sessionId, value: "");
|
||||
}
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
if (!isSpecialHoldDragActive) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
|
||||
get onHoldDragCancel => null;
|
||||
|
||||
@@ -230,7 +230,6 @@ List<(String, String)> otherDefaultSettings() {
|
||||
('Disable clipboard', kOptionDisableClipboard),
|
||||
('Lock after session end', kOptionLockAfterSessionEnd),
|
||||
('Privacy mode', kOptionPrivacyMode),
|
||||
if (isMobile) ('Touch mode', kOptionTouchMode),
|
||||
('True color (4:4:4)', kOptionI444),
|
||||
('Reverse mouse wheel', kKeyReverseMouseWheel),
|
||||
('swap-left-right-mouse', kOptionSwapLeftRightMouse),
|
||||
|
||||
@@ -363,6 +363,11 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
|
||||
child: Text(translate('Scale adaptive')),
|
||||
value: kRemoteViewStyleAdaptive,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged),
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Scale custom')),
|
||||
value: kRemoteViewStyleCustom,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -155,6 +155,9 @@ const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
||||
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
@@ -172,6 +175,7 @@ const kHideUsernameOnCard = "hide-username-on-card";
|
||||
const String kOptionHideHelpCards = "hide-help-cards";
|
||||
|
||||
const String kOptionToggleViewOnly = "view-only";
|
||||
const String kOptionToggleShowMyCursor = "show-my-cursor";
|
||||
|
||||
const String kOptionDisableFloatingWindow = "disable-floating-window";
|
||||
|
||||
@@ -312,6 +316,10 @@ const kRemoteViewStyleOriginal = 'original';
|
||||
/// [kRemoteViewStyleAdaptive] Show remote image scaling by ratio factor.
|
||||
const kRemoteViewStyleAdaptive = 'adaptive';
|
||||
|
||||
/// [kRemoteViewStyleCustom] Show remote image at a user-defined scale percent.
|
||||
const kRemoteViewStyleCustom = 'custom';
|
||||
|
||||
|
||||
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
|
||||
const kRemoteScrollStyleAuto = 'scrollauto';
|
||||
|
||||
@@ -344,6 +352,15 @@ const Set<PointerDeviceKind> kTouchBasedDeviceKinds = {
|
||||
PointerDeviceKind.invertedStylus,
|
||||
};
|
||||
|
||||
// Scale custom related constants
|
||||
const String kCustomScalePercentKey = 'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
|
||||
const int kScaleCustomMinPercent = 5;
|
||||
const int kScaleCustomPivotPercent = 100; // 100% should be at 1/3 of track
|
||||
const int kScaleCustomMaxPercent = 1000;
|
||||
const double kScaleCustomPivotPos = 1.0 / 3.0; // first 1/3 → up to 100%
|
||||
const double kScaleCustomDetentEpsilon = 0.006; // snap range around pivot (~0.6%)
|
||||
const Duration kDebounceCustomScaleDuration = Duration(milliseconds: 300);
|
||||
|
||||
// ================================ mobile ================================
|
||||
|
||||
// Magic numbers, maybe need to avoid it or use a better way to get them.
|
||||
|
||||
@@ -374,6 +374,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
note: '',
|
||||
);
|
||||
_autocompleteOpts = [emptyPeer];
|
||||
} else {
|
||||
@@ -536,64 +537,68 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
builder: (context, setState) {
|
||||
var offset = Offset(0, 0);
|
||||
return Obx(() => InkWell(
|
||||
child: _menuOpen.value
|
||||
? Transform.rotate(
|
||||
angle: pi,
|
||||
child: Icon(IconFont.more, size: 14),
|
||||
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)
|
||||
),
|
||||
(
|
||||
'${translate('Terminal')} (beta)',
|
||||
() => onConnect(isTerminal: 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,
|
||||
)
|
||||
: 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)
|
||||
),
|
||||
(
|
||||
'${translate('Terminal')} (beta)',
|
||||
() => onConnect(isTerminal: 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;
|
||||
});
|
||||
},
|
||||
));
|
||||
.then((_) {
|
||||
_menuOpen.value = false;
|
||||
});
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -434,7 +434,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
!isCardClosed &&
|
||||
bind.mainUriPrefixSync().contains('rustdesk')) {
|
||||
final isToUpdate = (isWindows || isMacOS) && bind.mainIsInstalled();
|
||||
String btnText = isToUpdate ? 'Click to update' : 'Click to download';
|
||||
String btnText = isToUpdate ? 'Update' : 'Download';
|
||||
GestureTapCallback onPressed = () async {
|
||||
final Uri url = Uri.parse('https://rustdesk.com/download');
|
||||
await launchUrl(url);
|
||||
|
||||
@@ -146,16 +146,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
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");
|
||||
}
|
||||
String msgConn = getConnectionText(
|
||||
secure, direct, connectionType.stream_type.value);
|
||||
var msgFingerprint = '${translate('Fingerprint')}:\n';
|
||||
var fingerprint = FingerprintState.find(key).value;
|
||||
if (fingerprint.isEmpty) {
|
||||
|
||||
@@ -145,16 +145,8 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
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");
|
||||
}
|
||||
String msgConn = getConnectionText(
|
||||
secure, direct, connectionType.stream_type.value);
|
||||
var msgFingerprint = '${translate('Fingerprint')}:\n';
|
||||
var fingerprint = FingerprintState.find(key).value;
|
||||
if (fingerprint.isEmpty) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import '../../models/platform_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import './popup_menu.dart';
|
||||
import './kb_layout_type_chooser.dart';
|
||||
import 'package:flutter_hbb/utils/scale.dart';
|
||||
|
||||
class ToolbarState {
|
||||
late RxBool _pin;
|
||||
@@ -175,6 +176,12 @@ class RemoteMenuEntry {
|
||||
dismissOnClicked: true,
|
||||
dismissCallback: dismissCallback,
|
||||
),
|
||||
MenuEntryRadioOption(
|
||||
text: translate('Scale custom'),
|
||||
value: kRemoteViewStyleCustom,
|
||||
dismissOnClicked: true,
|
||||
dismissCallback: dismissCallback,
|
||||
),
|
||||
],
|
||||
curOptionGetter: () async {
|
||||
// null means peer id is not found, which there's no need to care about
|
||||
@@ -1024,6 +1031,7 @@ class _DisplayMenu extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
final RxInt _customPercent = 100.obs;
|
||||
late final ScreenAdjustor _screenAdjustor = ScreenAdjustor(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
@@ -1037,13 +1045,27 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
FFI get ffi => widget.ffi;
|
||||
String get id => widget.id;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize custom percent from stored option once
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
final v = await getSessionCustomScalePercent(widget.ffi.sessionId);
|
||||
if (_customPercent.value != v) {
|
||||
_customPercent.value = v;
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_screenAdjustor.updateScreen();
|
||||
menuChildrenGetter() {
|
||||
final menuChildren = <Widget>[
|
||||
_screenAdjustor.adjustWindow(context),
|
||||
viewStyle(),
|
||||
viewStyle(customPercent: _customPercent),
|
||||
scrollStyle(),
|
||||
imageQuality(),
|
||||
codec(),
|
||||
@@ -1108,30 +1130,69 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
viewStyle() {
|
||||
viewStyle({required RxInt customPercent}) {
|
||||
return futureBuilder(
|
||||
future: toolbarViewStyle(context, widget.id, widget.ffi),
|
||||
hasData: (data) {
|
||||
final v = data as List<TRadioMenu<String>>;
|
||||
final bool isCustomSelected = v.isNotEmpty
|
||||
? v.first.groupValue == kRemoteViewStyleCustom
|
||||
: false;
|
||||
return Column(children: [
|
||||
...v
|
||||
.map((e) => RdoMenuButton<String>(
|
||||
value: e.value,
|
||||
groupValue: e.groupValue,
|
||||
onChanged: e.onChanged,
|
||||
child: e.child,
|
||||
ffi: ffi))
|
||||
.toList(),
|
||||
Divider(),
|
||||
...v.map((e) {
|
||||
final isCustom = e.value == kRemoteViewStyleCustom;
|
||||
final child = isCustom
|
||||
? Text(translate('Scale custom'))
|
||||
: e.child;
|
||||
// Whether the current selection is already custom
|
||||
final bool isGroupCustomSelected =
|
||||
e.groupValue == kRemoteViewStyleCustom;
|
||||
// Keep menu open when switching INTO custom so the slider is visible immediately
|
||||
final bool keepOpenForThisItem = isCustom && !isGroupCustomSelected;
|
||||
return RdoMenuButton<String>(
|
||||
value: e.value,
|
||||
groupValue: e.groupValue,
|
||||
onChanged: (value) {
|
||||
// Perform the original change
|
||||
e.onChanged?.call(value);
|
||||
// Only force a rebuild when we keep the menu open to reveal the slider
|
||||
if (keepOpenForThisItem) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
ffi: ffi,
|
||||
// When entering custom, keep submenu open to show the slider controls
|
||||
closeOnActivate: !keepOpenForThisItem);
|
||||
}).toList(),
|
||||
// Only show a divider when custom is NOT selected
|
||||
if (!isCustomSelected) Divider(),
|
||||
_customControlsIfCustomSelected(onChanged: (v) => customPercent.value = v),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _customControlsIfCustomSelected({ValueChanged<int>? onChanged}) {
|
||||
return futureBuilder(future: () async {
|
||||
final current = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
|
||||
return current == kRemoteViewStyleCustom;
|
||||
}(), hasData: (data) {
|
||||
final isCustom = data as bool;
|
||||
return AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 220),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: isCustom ? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged) : SizedBox.shrink(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
scrollStyle() {
|
||||
return futureBuilder(future: () async {
|
||||
final viewStyle =
|
||||
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
||||
final visible = viewStyle == kRemoteViewStyleOriginal;
|
||||
final visible = viewStyle == kRemoteViewStyleOriginal ||
|
||||
viewStyle == kRemoteViewStyleCustom;
|
||||
final scrollStyle =
|
||||
await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? '';
|
||||
return {'visible': visible, 'scrollStyle': scrollStyle};
|
||||
@@ -1146,24 +1207,27 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
widget.ffi.canvasModel.updateScrollStyle();
|
||||
}
|
||||
|
||||
final enabled = widget.ffi.canvasModel.imageOverflow.value;
|
||||
return Column(children: [
|
||||
RdoMenuButton<String>(
|
||||
child: Text(translate('ScrollAuto')),
|
||||
value: kRemoteScrollStyleAuto,
|
||||
groupValue: groupValue,
|
||||
onChanged: enabled ? (value) => onChange(value) : null,
|
||||
ffi: widget.ffi,
|
||||
),
|
||||
RdoMenuButton<String>(
|
||||
child: Text(translate('Scrollbar')),
|
||||
value: kRemoteScrollStyleBar,
|
||||
groupValue: groupValue,
|
||||
onChanged: enabled ? (value) => onChange(value) : null,
|
||||
ffi: widget.ffi,
|
||||
),
|
||||
Divider(),
|
||||
]);
|
||||
return Obx(() => Column(children: [
|
||||
RdoMenuButton<String>(
|
||||
child: Text(translate('ScrollAuto')),
|
||||
value: kRemoteScrollStyleAuto,
|
||||
groupValue: groupValue,
|
||||
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||
? (value) => onChange(value)
|
||||
: null,
|
||||
ffi: widget.ffi,
|
||||
),
|
||||
RdoMenuButton<String>(
|
||||
child: Text(translate('Scrollbar')),
|
||||
value: kRemoteScrollStyleBar,
|
||||
groupValue: groupValue,
|
||||
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||
? (value) => onChange(value)
|
||||
: null,
|
||||
ffi: widget.ffi,
|
||||
),
|
||||
Divider(),
|
||||
]));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1245,6 +1309,296 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomScaleMenuControls extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final ValueChanged<int>? onChanged;
|
||||
const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_CustomScaleMenuControls> createState() => _CustomScaleMenuControlsState();
|
||||
}
|
||||
|
||||
class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
|
||||
late int _value;
|
||||
late final Debouncer<int> _debouncerScale;
|
||||
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
|
||||
double _pos = 0.0;
|
||||
|
||||
// Piecewise mapping constants (moved to consts.dart)
|
||||
static const int _minPercent = kScaleCustomMinPercent;
|
||||
static const int _pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
|
||||
static const int _maxPercent = kScaleCustomMaxPercent;
|
||||
static const double _pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
|
||||
static const double _detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
|
||||
|
||||
// Clamp helper for local use
|
||||
int _clamp(int v) => clampCustomScalePercent(v);
|
||||
|
||||
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
|
||||
int _mapPosToPercent(double p) {
|
||||
if (p <= 0.0) return _minPercent;
|
||||
if (p >= 1.0) return _maxPercent;
|
||||
if (p <= _pivotPos) {
|
||||
final q = p / _pivotPos; // 0..1
|
||||
final v = _minPercent + q * (_pivotPercent - _minPercent);
|
||||
return _clamp(v.round());
|
||||
} else {
|
||||
final q = (p - _pivotPos) / (1.0 - _pivotPos); // 0..1
|
||||
final v = _pivotPercent + q * (_maxPercent - _pivotPercent);
|
||||
return _clamp(v.round());
|
||||
}
|
||||
}
|
||||
|
||||
// Map percent [5,1000] → normalized position [0,1]
|
||||
double _mapPercentToPos(int percent) {
|
||||
final p = _clamp(percent);
|
||||
if (p <= _pivotPercent) {
|
||||
final q = (p - _minPercent) / (_pivotPercent - _minPercent);
|
||||
return q * _pivotPos;
|
||||
} else {
|
||||
final q = (p - _pivotPercent) / (_maxPercent - _pivotPercent);
|
||||
return _pivotPos + q * (1.0 - _pivotPos);
|
||||
}
|
||||
}
|
||||
|
||||
// Snap normalized position to the pivot when close to it
|
||||
double _snapNormalizedPos(double p) {
|
||||
if ((p - _pivotPos).abs() <= _detentEpsilon) return _pivotPos;
|
||||
if (p < 0.0) return 0.0;
|
||||
if (p > 1.0) return 1.0;
|
||||
return p;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_value = 100;
|
||||
_debouncerScale = Debouncer<int>(
|
||||
kDebounceCustomScaleDuration,
|
||||
onChanged: (v) async {
|
||||
await _apply(v);
|
||||
},
|
||||
initialValue: _value,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
final v = await getSessionCustomScalePercent(widget.ffi.sessionId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_value = v;
|
||||
_pos = _mapPercentToPos(v);
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Failed to get initial value: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<void> _apply(int v) async {
|
||||
v = clampCustomScalePercent(v);
|
||||
setState(() {
|
||||
_value = v;
|
||||
});
|
||||
try {
|
||||
await bind.sessionSetFlutterOption(
|
||||
sessionId: widget.ffi.sessionId,
|
||||
k: kCustomScalePercentKey,
|
||||
v: v.toString());
|
||||
final curStyle = await bind.sessionGetViewStyle(sessionId: widget.ffi.sessionId);
|
||||
if (curStyle != kRemoteViewStyleCustom) {
|
||||
await bind.sessionSetViewStyle(
|
||||
sessionId: widget.ffi.sessionId, value: kRemoteViewStyleCustom);
|
||||
}
|
||||
await widget.ffi.canvasModel.updateViewStyle();
|
||||
if (isMobile) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
widget.onChanged?.call(v);
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Apply failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
void _nudge(int delta) {
|
||||
final next = _clamp(_value + delta);
|
||||
setState(() {
|
||||
_value = next;
|
||||
_pos = _mapPercentToPos(next);
|
||||
});
|
||||
widget.onChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncerScale.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
const smallBtnConstraints = BoxConstraints(minWidth: 28, minHeight: 28);
|
||||
|
||||
final sliderControl = Semantics(
|
||||
label: translate('Custom scale slider'),
|
||||
value: '$_value%',
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: colorScheme.primary,
|
||||
thumbColor: colorScheme.primary,
|
||||
overlayColor: colorScheme.primary.withOpacity(0.1),
|
||||
showValueIndicator: ShowValueIndicator.never,
|
||||
thumbShape: _RectValueThumbShape(
|
||||
min: _minPercent.toDouble(),
|
||||
max: _maxPercent.toDouble(),
|
||||
width: 52,
|
||||
height: 24,
|
||||
radius: 4,
|
||||
// Display the mapped percent for the current normalized value
|
||||
displayValueForNormalized: (t) => _mapPosToPercent(t),
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
value: _pos,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
// Use a wide range of divisions (calculated as (_maxPercent - _minPercent)) to provide ~1% precision increments.
|
||||
// This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges.
|
||||
divisions: (_maxPercent - _minPercent).round(),
|
||||
onChanged: (v) {
|
||||
final snapped = _snapNormalizedPos(v);
|
||||
final next = _mapPosToPercent(snapped);
|
||||
if (next != _value || snapped != _pos) {
|
||||
setState(() {
|
||||
_pos = snapped;
|
||||
_value = next;
|
||||
});
|
||||
widget.onChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(children: [
|
||||
Tooltip(
|
||||
message: translate('Decrease'),
|
||||
child: IconButton(
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(1),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: () => _nudge(-1),
|
||||
),
|
||||
),
|
||||
Expanded(child: sliderControl),
|
||||
Tooltip(
|
||||
message: translate('Increase'),
|
||||
child: IconButton(
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(1),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _nudge(1),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Divider(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight rectangular thumb that paints the current percentage.
|
||||
// Stateless and uses only SliderTheme colors; avoids allocations beyond a TextPainter per frame.
|
||||
class _RectValueThumbShape extends SliderComponentShape {
|
||||
final double min;
|
||||
final double max;
|
||||
final double width;
|
||||
final double height;
|
||||
final double radius;
|
||||
// Optional mapper to compute display value from normalized position [0,1]
|
||||
// If null, falls back to linear interpolation between min and max.
|
||||
final int Function(double normalized)? displayValueForNormalized;
|
||||
|
||||
const _RectValueThumbShape({
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.radius,
|
||||
this.displayValueForNormalized,
|
||||
});
|
||||
|
||||
@override
|
||||
Size getPreferredSize(bool isEnabled, bool isDiscrete) {
|
||||
return Size(width, height);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(
|
||||
PaintingContext context,
|
||||
Offset center, {
|
||||
required Animation<double> activationAnimation,
|
||||
required Animation<double> enableAnimation,
|
||||
required bool isDiscrete,
|
||||
required TextPainter labelPainter,
|
||||
required RenderBox parentBox,
|
||||
required SliderThemeData sliderTheme,
|
||||
required TextDirection textDirection,
|
||||
required double value,
|
||||
required double textScaleFactor,
|
||||
required Size sizeWithOverflow,
|
||||
}) {
|
||||
final Canvas canvas = context.canvas;
|
||||
|
||||
// Resolve color based on enabled/disabled animation, with safe fallbacks.
|
||||
final ColorTween colorTween = ColorTween(
|
||||
begin: sliderTheme.disabledThumbColor,
|
||||
end: sliderTheme.thumbColor,
|
||||
);
|
||||
final Color? evaluatedColor = colorTween.evaluate(enableAnimation);
|
||||
final Color? thumbColor = sliderTheme.thumbColor;
|
||||
final Color fillColor = evaluatedColor ?? thumbColor ?? Colors.blueAccent;
|
||||
|
||||
final RRect rrect = RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(center: center, width: width, height: height),
|
||||
Radius.circular(radius),
|
||||
);
|
||||
final Paint paint = Paint()..color = fillColor;
|
||||
canvas.drawRRect(rrect, paint);
|
||||
|
||||
// Compute displayed percent from normalized slider value.
|
||||
final int percent = displayValueForNormalized != null
|
||||
? displayValueForNormalized!(value)
|
||||
: (min + value * (max - min)).round();
|
||||
final TextSpan span = TextSpan(
|
||||
text: '$percent%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
final TextPainter tp = TextPainter(
|
||||
text: span,
|
||||
textAlign: TextAlign.center,
|
||||
textDirection: textDirection,
|
||||
);
|
||||
tp.layout(maxWidth: width - 4);
|
||||
tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
|
||||
}
|
||||
}
|
||||
|
||||
class _ResolutionsMenu extends StatefulWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
@@ -1593,6 +1947,9 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
inputSource(),
|
||||
Divider(),
|
||||
viewMode(),
|
||||
if ([kPeerPlatformWindows, kPeerPlatformMacOS, kPeerPlatformLinux]
|
||||
.contains(pi.platform))
|
||||
showMyCursor(),
|
||||
Divider(),
|
||||
...toolbarToggles(),
|
||||
...mouseSpeed(),
|
||||
@@ -1749,12 +2106,43 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
final viewOnly = await bind.sessionGetToggleOption(
|
||||
sessionId: ffi.sessionId, arg: kOptionToggleViewOnly);
|
||||
ffiModel.setViewOnly(id, viewOnly ?? value);
|
||||
final showMyCursor = await bind.sessionGetToggleOption(
|
||||
sessionId: ffi.sessionId, arg: kOptionToggleShowMyCursor);
|
||||
ffiModel.setShowMyCursor(showMyCursor ?? value);
|
||||
}
|
||||
: null,
|
||||
ffi: ffi,
|
||||
child: Text(translate('View Mode')));
|
||||
}
|
||||
|
||||
showMyCursor() {
|
||||
final ffiModel = ffi.ffiModel;
|
||||
return CkbMenuButton(
|
||||
value: ffiModel.showMyCursor,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await bind.sessionToggleOption(
|
||||
sessionId: ffi.sessionId, value: kOptionToggleShowMyCursor);
|
||||
final showMyCursor = await bind.sessionGetToggleOption(
|
||||
sessionId: ffi.sessionId,
|
||||
arg: kOptionToggleShowMyCursor) ??
|
||||
value;
|
||||
ffiModel.setShowMyCursor(showMyCursor);
|
||||
|
||||
// Also set view only if showMyCursor is enabled and viewOnly is not enabled.
|
||||
if (showMyCursor && !ffiModel.viewOnly) {
|
||||
await bind.sessionToggleOption(
|
||||
sessionId: ffi.sessionId, value: kOptionToggleViewOnly);
|
||||
final viewOnly = await bind.sessionGetToggleOption(
|
||||
sessionId: ffi.sessionId, arg: kOptionToggleViewOnly);
|
||||
ffiModel.setViewOnly(id, viewOnly ?? value);
|
||||
}
|
||||
},
|
||||
ffi: ffi,
|
||||
child: Text(translate('Show my cursor')))
|
||||
.paddingOnly(left: 26.0);
|
||||
}
|
||||
|
||||
mobileActions() {
|
||||
if (pi.platform != kPeerPlatformAndroid) return [];
|
||||
final enabled = versionCmp(pi.version, '1.2.7') >= 0;
|
||||
@@ -2232,6 +2620,8 @@ class RdoMenuButton<T> extends StatelessWidget {
|
||||
final ValueChanged<T?>? onChanged;
|
||||
final Widget? child;
|
||||
final FFI? ffi;
|
||||
// When true, submenu will be dismissed on activate; when false, it stays open.
|
||||
final bool closeOnActivate;
|
||||
const RdoMenuButton({
|
||||
Key? key,
|
||||
required this.value,
|
||||
@@ -2239,6 +2629,7 @@ class RdoMenuButton<T> extends StatelessWidget {
|
||||
required this.child,
|
||||
this.ffi,
|
||||
this.onChanged,
|
||||
this.closeOnActivate = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -2247,9 +2638,10 @@ class RdoMenuButton<T> extends StatelessWidget {
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
child: child,
|
||||
closeOnActivate: closeOnActivate,
|
||||
onChanged: onChanged != null
|
||||
? (T? value) {
|
||||
if (ffi != null) {
|
||||
if (ffi != null && closeOnActivate) {
|
||||
_menuDismissCallback(ffi!);
|
||||
}
|
||||
onChanged?.call(value);
|
||||
|
||||
@@ -292,7 +292,6 @@ class DesktopTab extends StatefulWidget {
|
||||
// ignore: must_be_immutable
|
||||
class _DesktopTabState extends State<DesktopTab>
|
||||
with MultiWindowListener, WindowListener {
|
||||
final _saveFrameDebounce = Debouncer(delay: Duration(seconds: 1));
|
||||
Timer? _macOSCheckRestoreTimer;
|
||||
int _macOSCheckRestoreCounter = 0;
|
||||
|
||||
@@ -370,7 +369,7 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
|
||||
void _setMaximized(bool maximize) {
|
||||
stateGlobal.setMaximized(maximize);
|
||||
_saveFrameDebounce.call(_saveFrame);
|
||||
_saveFrame();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -405,24 +404,24 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
super.onWindowUnmaximize();
|
||||
}
|
||||
|
||||
_saveFrame() async {
|
||||
_saveFrame({bool? flush}) async {
|
||||
if (tabType == DesktopTabType.main) {
|
||||
await saveWindowPosition(WindowType.Main);
|
||||
await saveWindowPosition(WindowType.Main, flush: flush);
|
||||
} else if (kWindowType != null && kWindowId != null) {
|
||||
await saveWindowPosition(kWindowType!, windowId: kWindowId);
|
||||
await saveWindowPosition(kWindowType!, windowId: kWindowId, flush: flush);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMoved() {
|
||||
_saveFrameDebounce.call(_saveFrame);
|
||||
_saveFrame();
|
||||
super.onWindowMoved();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResized() {
|
||||
_saveFrameDebounce.call(_saveFrame);
|
||||
super.onWindowMoved();
|
||||
_saveFrame();
|
||||
super.onWindowResized();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -460,6 +459,8 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
});
|
||||
}
|
||||
|
||||
await _saveFrame(flush: true);
|
||||
|
||||
// hide window on close
|
||||
if (isMainWindow) {
|
||||
if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) {
|
||||
|
||||
@@ -7,7 +7,10 @@ import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
final _isExtracting = false.obs;
|
||||
|
||||
void handleUpdate(String releasePageUrl) {
|
||||
_isExtracting.value = false;
|
||||
String downloadUrl = releasePageUrl.replaceAll('tag', 'download');
|
||||
String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
|
||||
final String downloadFile =
|
||||
@@ -25,13 +28,15 @@ void handleUpdate(String releasePageUrl) {
|
||||
gFFI.dialogManager.dismissAll();
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate('Downloading {$appName}')),
|
||||
title: Obx(() => Text(translate(_isExtracting.isTrue
|
||||
? 'Preparing for installation ...'
|
||||
: 'Downloading {$appName}'))),
|
||||
content:
|
||||
UpdateProgress(releasePageUrl, downloadUrl, downloadId, onCanceled)
|
||||
.marginSymmetric(horizontal: 8)
|
||||
.paddingOnly(top: 12),
|
||||
actions: [
|
||||
dialogButton(translate('Cancel'), onPressed: () async {
|
||||
if (_isExtracting.isFalse) dialogButton(translate('Cancel'), onPressed: () async {
|
||||
onCanceled.value();
|
||||
await bind.mainSetCommon(
|
||||
key: 'cancel-downloader', value: downloadId.value);
|
||||
@@ -71,6 +76,7 @@ class UpdateProgressState extends State<UpdateProgress> {
|
||||
int _downloadedSize = 0;
|
||||
int _getDataFailedCount = 0;
|
||||
final String _eventKeyDownloadNewVersion = 'download-new-version';
|
||||
final String _eventKeyExtractUpdateDmg = 'extract-update-dmg';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -82,6 +88,11 @@ class UpdateProgressState extends State<UpdateProgress> {
|
||||
_eventKeyDownloadNewVersion, handleDownloadNewVersion,
|
||||
replace: true);
|
||||
bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl);
|
||||
if (isMacOS) {
|
||||
platformFFI.registerEventHandler(_eventKeyExtractUpdateDmg,
|
||||
_eventKeyExtractUpdateDmg, handleExtractUpdateDmg,
|
||||
replace: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,6 +100,10 @@ class UpdateProgressState extends State<UpdateProgress> {
|
||||
cancelQueryTimer();
|
||||
platformFFI.unregisterEventHandler(
|
||||
_eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion);
|
||||
if (isMacOS) {
|
||||
platformFFI.unregisterEventHandler(
|
||||
_eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -113,10 +128,13 @@ class UpdateProgressState extends State<UpdateProgress> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(String error) {
|
||||
// `isExtractDmg` is true when handling extract-update-dmg event.
|
||||
// It's a rare case that the dmg file is corrupted and cannot be extracted.
|
||||
void _onError(String error, {bool isExtractDmg = false}) {
|
||||
cancelQueryTimer();
|
||||
|
||||
debugPrint('Download new version error: $error');
|
||||
debugPrint(
|
||||
'${isExtractDmg ? "Extract" : "Download"} new version error: $error');
|
||||
final msgBoxType = 'custom-nocancel-nook-hasclose';
|
||||
final msgBoxTitle = 'Error';
|
||||
final msgBoxText = 'download-new-version-failed-tip';
|
||||
@@ -138,7 +156,7 @@ class UpdateProgressState extends State<UpdateProgress> {
|
||||
|
||||
final List<Widget> buttons = [
|
||||
dialogButton('Download', onPressed: jumplink),
|
||||
dialogButton('Retry', onPressed: retry),
|
||||
if (!isExtractDmg) dialogButton('Retry', onPressed: retry),
|
||||
dialogButton('Close', onPressed: close),
|
||||
];
|
||||
dialogManager.dismissAll();
|
||||
@@ -194,19 +212,13 @@ class UpdateProgressState extends State<UpdateProgress> {
|
||||
_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,
|
||||
);
|
||||
if (isMacOS) {
|
||||
bind.mainSetCommon(
|
||||
key: 'extract-update-dmg', value: widget.downloadUrl);
|
||||
_isExtracting.value = true;
|
||||
} else {
|
||||
updateMsgBox();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setState(() {});
|
||||
@@ -214,17 +226,38 @@ class UpdateProgressState extends State<UpdateProgress> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return onDownloading(context);
|
||||
void updateMsgBox() {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
Widget onDownloading(BuildContext context) {
|
||||
final value = _totalSize == null
|
||||
Future<void> handleExtractUpdateDmg(Map<String, dynamic> evt) async {
|
||||
_isExtracting.value = false;
|
||||
if (evt.containsKey('err') && (evt['err'] as String).isNotEmpty) {
|
||||
_onError(evt['err'] as String, isExtractDmg: true);
|
||||
} else {
|
||||
updateMsgBox();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
getValue() => _totalSize == null
|
||||
? 0.0
|
||||
: (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!);
|
||||
return LinearProgressIndicator(
|
||||
value: value,
|
||||
value: _isExtracting.isTrue ? null : getValue(),
|
||||
minHeight: 20,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
backgroundColor: Colors.grey[300],
|
||||
|
||||
@@ -147,9 +147,15 @@ void runMainApp(bool startService) async {
|
||||
gFFI.userModel.refreshCurrentUser();
|
||||
runApp(App());
|
||||
|
||||
bool? alwaysOnTop;
|
||||
if (isDesktop) {
|
||||
alwaysOnTop =
|
||||
bind.mainGetBuildinOption(key: "main-window-always-on-top") == 'Y';
|
||||
}
|
||||
|
||||
// Set window option.
|
||||
WindowOptions windowOptions =
|
||||
getHiddenTitleBarWindowOptions(isMainWindow: true);
|
||||
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
|
||||
isMainWindow: true, alwaysOnTop: alwaysOnTop);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
// Restore the location of the main window before window hide or show.
|
||||
await restoreWindowPosition(WindowType.Main);
|
||||
|
||||
@@ -182,6 +182,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
note: '',
|
||||
);
|
||||
_autocompleteOpts = [emptyPeer];
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,8 @@ 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/mobile/widgets/floating_mouse.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/floating_mouse_widgets.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/gesture_help.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
@@ -40,7 +42,12 @@ void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
|
||||
}
|
||||
|
||||
class RemotePage extends StatefulWidget {
|
||||
RemotePage({Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
|
||||
RemotePage(
|
||||
{Key? key,
|
||||
required this.id,
|
||||
this.password,
|
||||
this.isSharedPassword,
|
||||
this.forceRelay})
|
||||
: super(key: key);
|
||||
|
||||
final String id;
|
||||
@@ -612,6 +619,15 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
if (showCursorPaint) {
|
||||
paints.add(CursorPaint(widget.id));
|
||||
}
|
||||
if (gFFI.ffiModel.touchMode) {
|
||||
paints.add(FloatingMouse(
|
||||
ffi: gFFI,
|
||||
));
|
||||
} else {
|
||||
paints.add(FloatingMouseWidgets(
|
||||
ffi: gFFI,
|
||||
));
|
||||
}
|
||||
return paints;
|
||||
}()));
|
||||
}
|
||||
@@ -784,13 +800,14 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
controller: ScrollController(),
|
||||
padding: EdgeInsets.symmetric(vertical: 10),
|
||||
child: GestureHelp(
|
||||
touchMode: gFFI.ffiModel.touchMode,
|
||||
onTouchModeChange: (t) {
|
||||
gFFI.ffiModel.toggleTouchMode();
|
||||
final v = gFFI.ffiModel.touchMode ? 'Y' : '';
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: kOptionTouchMode, value: v);
|
||||
})));
|
||||
touchMode: gFFI.ffiModel.touchMode,
|
||||
onTouchModeChange: (t) {
|
||||
gFFI.ffiModel.toggleTouchMode();
|
||||
final v = gFFI.ffiModel.touchMode ? 'Y' : 'N';
|
||||
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
|
||||
},
|
||||
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
|
||||
)));
|
||||
}
|
||||
|
||||
// * Currently mobile does not enable map mode
|
||||
@@ -1105,7 +1122,7 @@ void showOptions(
|
||||
BuildContext context, String id, OverlayDialogManager dialogManager) async {
|
||||
var displays = <Widget>[];
|
||||
final pi = gFFI.ffiModel.pi;
|
||||
final image = gFFI.ffiModel.getConnectionImage();
|
||||
final image = gFFI.ffiModel.getConnectionImageText();
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
|
||||
@@ -39,7 +39,11 @@ void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
|
||||
|
||||
class ViewCameraPage extends StatefulWidget {
|
||||
ViewCameraPage(
|
||||
{Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
|
||||
{Key? key,
|
||||
required this.id,
|
||||
this.password,
|
||||
this.isSharedPassword,
|
||||
this.forceRelay})
|
||||
: super(key: key);
|
||||
|
||||
final String id;
|
||||
@@ -579,7 +583,7 @@ void showOptions(
|
||||
BuildContext context, String id, OverlayDialogManager dialogManager) async {
|
||||
var displays = <Widget>[];
|
||||
final pi = gFFI.ffiModel.pi;
|
||||
final image = gFFI.ffiModel.getConnectionImage();
|
||||
final image = gFFI.ffiModel.getConnectionImageText();
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
|
||||
1209
flutter/lib/mobile/widgets/floating_mouse.dart
Normal file
1209
flutter/lib/mobile/widgets/floating_mouse.dart
Normal file
File diff suppressed because it is too large
Load Diff
880
flutter/lib/mobile/widgets/floating_mouse_widgets.dart
Normal file
880
flutter/lib/mobile/widgets/floating_mouse_widgets.dart
Normal file
@@ -0,0 +1,880 @@
|
||||
// These floating mouse widgets are used to simulate a physical mouse
|
||||
// when "mobile" -> "desktop" in mouse mode.
|
||||
// This file does not contain whole mouse widgets, it only contains
|
||||
// parts that help to control, such as wheel scroll and wheel button.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
// Used for the wheel button and wheel scroll widgets
|
||||
const double _kSpaceToHorizontalEdge = 25;
|
||||
const double _wheelWidth = 50;
|
||||
const double _wheelHeight = 162;
|
||||
// Used for the left/right button widgets
|
||||
const double _kSpaceToVerticalEdge = 15;
|
||||
const double _kSpaceBetweenLeftRightButtons = 40;
|
||||
const double _kLeftRightButtonWidth = 55;
|
||||
const double _kLeftRightButtonHeight = 40;
|
||||
const double _kBorderWidth = 1;
|
||||
final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7);
|
||||
final Color _kDefaultColor = Colors.black.withOpacity(0.4);
|
||||
final Color _kTapDownColor = Colors.blue.withOpacity(0.7);
|
||||
final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9);
|
||||
const int _kInputTimerIntervalMillis = 100;
|
||||
|
||||
class FloatingMouseWidgets extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
const FloatingMouseWidgets({
|
||||
super.key,
|
||||
required this.ffi,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FloatingMouseWidgets> createState() => _FloatingMouseWidgetsState();
|
||||
}
|
||||
|
||||
class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
|
||||
InputModel get _inputModel => widget.ffi.inputModel;
|
||||
CursorModel get _cursorModel => widget.ffi.cursorModel;
|
||||
late final VirtualMouseMode _virtualMouseMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode;
|
||||
_virtualMouseMode.addListener(_onVirtualMouseModeChanged);
|
||||
_cursorModel.blockEvents = false;
|
||||
isSpecialHoldDragActive = false;
|
||||
}
|
||||
|
||||
void _onVirtualMouseModeChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_virtualMouseMode.removeListener(_onVirtualMouseModeChanged);
|
||||
super.dispose();
|
||||
_cursorModel.blockEvents = false;
|
||||
isSpecialHoldDragActive = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final virtualMouseMode = _virtualMouseMode;
|
||||
if (!virtualMouseMode.showVirtualMouse) {
|
||||
return const Offstage();
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
FloatingWheel(
|
||||
inputModel: _inputModel,
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
if (virtualMouseMode.showVirtualJoystick)
|
||||
VirtualJoystick(cursorModel: _cursorModel),
|
||||
FloatingLeftRightButton(
|
||||
isLeft: true,
|
||||
inputModel: _inputModel,
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
FloatingLeftRightButton(
|
||||
isLeft: false,
|
||||
inputModel: _inputModel,
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FloatingWheel extends StatefulWidget {
|
||||
final InputModel inputModel;
|
||||
final CursorModel cursorModel;
|
||||
const FloatingWheel(
|
||||
{super.key, required this.inputModel, required this.cursorModel});
|
||||
|
||||
@override
|
||||
State<FloatingWheel> createState() => _FloatingWheelState();
|
||||
}
|
||||
|
||||
class _FloatingWheelState extends State<FloatingWheel> {
|
||||
Offset _position = Offset.zero;
|
||||
bool _isInitialized = false;
|
||||
Rect? _lastBlockedRect;
|
||||
|
||||
bool _isUpDown = false;
|
||||
bool _isMidDown = false;
|
||||
bool _isDownDown = false;
|
||||
|
||||
Orientation? _previousOrientation;
|
||||
|
||||
Timer? _scrollTimer;
|
||||
|
||||
InputModel get _inputModel => widget.inputModel;
|
||||
CursorModel get _cursorModel => widget.cursorModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_resetPosition();
|
||||
});
|
||||
}
|
||||
|
||||
void _resetPosition() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
setState(() {
|
||||
_position = Offset(
|
||||
size.width - _wheelWidth - _kSpaceToHorizontalEdge,
|
||||
(size.height - _wheelHeight) / 2,
|
||||
);
|
||||
_isInitialized = true;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _updateBlockedRect();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateBlockedRect() {
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
final newRect =
|
||||
Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight);
|
||||
_cursorModel.addBlockedRect(newRect);
|
||||
_lastBlockedRect = newRect;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollTimer?.cancel();
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final currentOrientation = MediaQuery.of(context).orientation;
|
||||
if (_previousOrientation != null &&
|
||||
_previousOrientation != currentOrientation) {
|
||||
_resetPosition();
|
||||
}
|
||||
_previousOrientation = currentOrientation;
|
||||
}
|
||||
|
||||
Widget _buildUpDownButton(
|
||||
void Function(PointerDownEvent) onPointerDown,
|
||||
void Function(PointerUpEvent) onPointerUp,
|
||||
void Function(PointerCancelEvent) onPointerCancel,
|
||||
bool Function() flagGetter,
|
||||
BorderRadiusGeometry borderRadius,
|
||||
IconData iconData) {
|
||||
return Listener(
|
||||
onPointerDown: onPointerDown,
|
||||
onPointerUp: onPointerUp,
|
||||
onPointerCancel: onPointerCancel,
|
||||
child: Container(
|
||||
width: _wheelWidth,
|
||||
height: 55,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _kDefaultColor,
|
||||
border: Border.all(
|
||||
color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor,
|
||||
width: 1),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: Icon(iconData, color: _kDefaultBorderColor, size: 32),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return Positioned(child: Offstage());
|
||||
}
|
||||
return Positioned(
|
||||
left: _position.dx,
|
||||
top: _position.dy,
|
||||
child: _buildWidget(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWidget(BuildContext context) {
|
||||
return Container(
|
||||
width: _wheelWidth,
|
||||
height: _wheelHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildUpDownButton(
|
||||
(event) {
|
||||
setState(() {
|
||||
_isUpDown = true;
|
||||
});
|
||||
_startScrollTimer(1);
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isUpDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isUpDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
() => _isUpDown,
|
||||
BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)),
|
||||
Icons.keyboard_arrow_up,
|
||||
),
|
||||
Listener(
|
||||
onPointerDown: (event) {
|
||||
setState(() {
|
||||
_isMidDown = true;
|
||||
});
|
||||
_inputModel.tapDown(MouseButtons.wheel);
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
setState(() {
|
||||
_isMidDown = false;
|
||||
});
|
||||
_inputModel.tapUp(MouseButtons.wheel);
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
setState(() {
|
||||
_isMidDown = false;
|
||||
});
|
||||
_inputModel.tapUp(MouseButtons.wheel);
|
||||
},
|
||||
child: Container(
|
||||
width: _wheelWidth,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: _kDefaultColor,
|
||||
border: Border.symmetric(
|
||||
vertical: BorderSide(
|
||||
color:
|
||||
_isMidDown ? _kTapDownColor : _kDefaultBorderColor,
|
||||
width: _kBorderWidth)),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: _wheelWidth - 10,
|
||||
height: _wheelWidth - 10,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 2,
|
||||
color: _kDefaultBorderColor,
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Container(
|
||||
width: 24,
|
||||
height: 2,
|
||||
color: _kDefaultBorderColor,
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Container(
|
||||
width: 18,
|
||||
height: 2,
|
||||
color: _kDefaultBorderColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildUpDownButton(
|
||||
(event) {
|
||||
setState(() {
|
||||
_isDownDown = true;
|
||||
});
|
||||
_startScrollTimer(-1);
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isDownDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isDownDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
() => _isDownDown,
|
||||
BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)),
|
||||
Icons.keyboard_arrow_down,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _startScrollTimer(int direction) {
|
||||
_scrollTimer?.cancel();
|
||||
_inputModel.scroll(direction);
|
||||
_scrollTimer = Timer.periodic(
|
||||
Duration(milliseconds: _kInputTimerIntervalMillis), (timer) {
|
||||
_inputModel.scroll(direction);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopScrollTimer() {
|
||||
_scrollTimer?.cancel();
|
||||
_scrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
class FloatingLeftRightButton extends StatefulWidget {
|
||||
final bool isLeft;
|
||||
final InputModel inputModel;
|
||||
final CursorModel cursorModel;
|
||||
const FloatingLeftRightButton(
|
||||
{super.key,
|
||||
required this.isLeft,
|
||||
required this.inputModel,
|
||||
required this.cursorModel});
|
||||
|
||||
@override
|
||||
State<FloatingLeftRightButton> createState() =>
|
||||
_FloatingLeftRightButtonState();
|
||||
}
|
||||
|
||||
class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
|
||||
Offset _position = Offset.zero;
|
||||
bool _isInitialized = false;
|
||||
bool _isDown = false;
|
||||
Rect? _lastBlockedRect;
|
||||
|
||||
Orientation? _previousOrientation;
|
||||
Offset _preSavedPos = Offset.zero;
|
||||
|
||||
// Gesture ambiguity resolution
|
||||
Timer? _tapDownTimer;
|
||||
final Duration _pressTimeout = const Duration(milliseconds: 200);
|
||||
bool _isDragging = false;
|
||||
|
||||
bool get _isLeft => widget.isLeft;
|
||||
InputModel get _inputModel => widget.inputModel;
|
||||
CursorModel get _cursorModel => widget.cursorModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final currentOrientation = MediaQuery.of(context).orientation;
|
||||
_previousOrientation = currentOrientation;
|
||||
_resetPosition(currentOrientation);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
_tapDownTimer?.cancel();
|
||||
_trySavePosition();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final currentOrientation = MediaQuery.of(context).orientation;
|
||||
if (_previousOrientation == null ||
|
||||
_previousOrientation != currentOrientation) {
|
||||
_resetPosition(currentOrientation);
|
||||
}
|
||||
_previousOrientation = currentOrientation;
|
||||
}
|
||||
|
||||
double _getOffsetX(double w) {
|
||||
if (_isLeft) {
|
||||
return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) *
|
||||
0.5;
|
||||
} else {
|
||||
return (w + _kSpaceBetweenLeftRightButtons) * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
String _getPositionKey(Orientation ori) {
|
||||
final strLeftRight = _isLeft ? 'l' : 'r';
|
||||
final strOri = ori == Orientation.landscape ? 'l' : 'p';
|
||||
return '$strLeftRight$strOri-mouse-btn-pos';
|
||||
}
|
||||
|
||||
static Offset? _loadPositionFromString(String s) {
|
||||
if (s.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final m = jsonDecode(s);
|
||||
return Offset(m['x'], m['y']);
|
||||
} catch (e) {
|
||||
debugPrintStack(label: 'Failed to load position "$s" $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _trySavePosition() {
|
||||
if (_previousOrientation == null) return;
|
||||
if (((_position - _preSavedPos)).distanceSquared < 0.1) return;
|
||||
final pos = jsonEncode({
|
||||
'x': _position.dx,
|
||||
'y': _position.dy,
|
||||
});
|
||||
bind.setLocalFlutterOption(
|
||||
k: _getPositionKey(_previousOrientation!), v: pos);
|
||||
_preSavedPos = _position;
|
||||
}
|
||||
|
||||
void _restorePosition(Orientation ori) {
|
||||
final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori));
|
||||
final pos = _loadPositionFromString(ps);
|
||||
if (pos == null) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
_position = Offset(_getOffsetX(size.width),
|
||||
size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight);
|
||||
} else {
|
||||
_position = pos;
|
||||
_preSavedPos = pos;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetPosition(Orientation ori) {
|
||||
setState(() {
|
||||
_restorePosition(ori);
|
||||
_isInitialized = true;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _updateBlockedRect();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateBlockedRect() {
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
final newRect = Rect.fromLTWH(_position.dx, _position.dy,
|
||||
_kLeftRightButtonWidth, _kLeftRightButtonHeight);
|
||||
_cursorModel.addBlockedRect(newRect);
|
||||
_lastBlockedRect = newRect;
|
||||
}
|
||||
|
||||
void _onMoveUpdateDelta(Offset delta) {
|
||||
final context = this.context;
|
||||
final size = MediaQuery.of(context).size;
|
||||
Offset newPosition = _position + delta;
|
||||
double minX = _kSpaceToHorizontalEdge;
|
||||
double minY = _kSpaceToVerticalEdge;
|
||||
double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge;
|
||||
double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge;
|
||||
newPosition = Offset(
|
||||
newPosition.dx.clamp(minX, maxX),
|
||||
newPosition.dy.clamp(minY, maxY),
|
||||
);
|
||||
final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) &&
|
||||
isDoubleEqual(newPosition.dy, _position.dy));
|
||||
setState(() {
|
||||
_position = newPosition;
|
||||
});
|
||||
if (isPositionChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _updateBlockedRect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onBodyPointerMoveUpdate(PointerMoveEvent event) {
|
||||
_cursorModel.blockEvents = true;
|
||||
// If move, it's a drag, not a tap.
|
||||
_isDragging = true;
|
||||
// Cancel the timer to prevent it from being recognized as a tap/hold.
|
||||
_tapDownTimer?.cancel();
|
||||
_tapDownTimer = null;
|
||||
_onMoveUpdateDelta(event.delta);
|
||||
}
|
||||
|
||||
Widget _buildButtonIcon() {
|
||||
final double w = _kLeftRightButtonWidth * 0.45;
|
||||
final double h = _kLeftRightButtonHeight * 0.75;
|
||||
final double borderRadius = w * 0.5;
|
||||
final double quarterCircleRadius = borderRadius * 0.9;
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: w,
|
||||
height: h,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _isLeft ? quarterCircleRadius * 0.25 : null,
|
||||
right: _isLeft ? null : quarterCircleRadius * 0.25,
|
||||
top: quarterCircleRadius * 0.25,
|
||||
child: CustomPaint(
|
||||
size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2),
|
||||
painter: _QuarterCirclePainter(
|
||||
color: _kDefaultColor,
|
||||
isLeft: _isLeft,
|
||||
radius: quarterCircleRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return Positioned(child: Offstage());
|
||||
}
|
||||
return Positioned(
|
||||
left: _position.dx,
|
||||
top: _position.dy,
|
||||
// We can't use the GestureDetector here, because `onTapDown` may be
|
||||
// triggered sometimes when dragging.
|
||||
child: Listener(
|
||||
onPointerMove: _onBodyPointerMoveUpdate,
|
||||
onPointerDown: (event) async {
|
||||
_isDragging = false;
|
||||
setState(() {
|
||||
_isDown = true;
|
||||
});
|
||||
// Start a timer. If it fires, it's a hold.
|
||||
_tapDownTimer?.cancel();
|
||||
_tapDownTimer = Timer(_pressTimeout, () {
|
||||
isSpecialHoldDragActive = true;
|
||||
() async {
|
||||
await _cursorModel.syncCursorPosition();
|
||||
await _inputModel
|
||||
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}();
|
||||
_tapDownTimer = null;
|
||||
});
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
_cursorModel.blockEvents = false;
|
||||
setState(() {
|
||||
_isDown = false;
|
||||
});
|
||||
// If timer is active, it's a quick tap.
|
||||
if (_tapDownTimer != null) {
|
||||
_tapDownTimer!.cancel();
|
||||
_tapDownTimer = null;
|
||||
// Fire tap down and up quickly.
|
||||
_inputModel
|
||||
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right)
|
||||
.then(
|
||||
(_) => Future.delayed(const Duration(milliseconds: 50), () {
|
||||
_inputModel.tapUp(
|
||||
_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}));
|
||||
} else {
|
||||
// If it's not a quick tap, it could be a hold or drag.
|
||||
// If it was a hold, isSpecialHoldDragActive is true.
|
||||
if (isSpecialHoldDragActive) {
|
||||
_inputModel
|
||||
.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}
|
||||
}
|
||||
|
||||
if (_isDragging) {
|
||||
_trySavePosition();
|
||||
}
|
||||
isSpecialHoldDragActive = false;
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
_cursorModel.blockEvents = false;
|
||||
setState(() {
|
||||
_isDown = false;
|
||||
});
|
||||
_tapDownTimer?.cancel();
|
||||
_tapDownTimer = null;
|
||||
if (isSpecialHoldDragActive) {
|
||||
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}
|
||||
isSpecialHoldDragActive = false;
|
||||
if (_isDragging) {
|
||||
_trySavePosition();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: _kLeftRightButtonWidth,
|
||||
height: _kLeftRightButtonHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _kDefaultColor,
|
||||
border: Border.all(
|
||||
color: _isDown ? _kTapDownColor : _kDefaultBorderColor,
|
||||
width: _kBorderWidth),
|
||||
borderRadius: _isLeft
|
||||
? BorderRadius.horizontal(
|
||||
left: Radius.circular(_kLeftRightButtonHeight * 0.5))
|
||||
: BorderRadius.horizontal(
|
||||
right: Radius.circular(_kLeftRightButtonHeight * 0.5)),
|
||||
),
|
||||
child: _buildButtonIcon(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuarterCirclePainter extends CustomPainter {
|
||||
final Color color;
|
||||
final bool isLeft;
|
||||
final double radius;
|
||||
_QuarterCirclePainter(
|
||||
{required this.color, required this.isLeft, required this.radius});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2);
|
||||
if (isLeft) {
|
||||
canvas.drawArc(rect, -pi, pi / 2, true, paint);
|
||||
} else {
|
||||
canvas.drawArc(rect, -pi / 2, pi / 2, true, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// Virtual joystick sends the absolute movement for now.
|
||||
// Maybe we need to change it to relative movement in the future.
|
||||
class VirtualJoystick extends StatefulWidget {
|
||||
final CursorModel cursorModel;
|
||||
|
||||
const VirtualJoystick({super.key, required this.cursorModel});
|
||||
|
||||
@override
|
||||
State<VirtualJoystick> createState() => _VirtualJoystickState();
|
||||
}
|
||||
|
||||
class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
Offset _position = Offset.zero;
|
||||
bool _isInitialized = false;
|
||||
Offset _offset = Offset.zero;
|
||||
final double _joystickRadius = 50.0;
|
||||
final double _thumbRadius = 20.0;
|
||||
final double _moveStep = 3.0;
|
||||
final double _speed = 1.0;
|
||||
|
||||
// One-shot timer to detect a drag gesture
|
||||
Timer? _dragStartTimer;
|
||||
// Periodic timer for continuous movement
|
||||
Timer? _continuousMoveTimer;
|
||||
Size? _lastScreenSize;
|
||||
bool _isPressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.cursorModel.blockEvents = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_lastScreenSize = MediaQuery.of(context).size;
|
||||
_resetPosition();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopSendEventTimer();
|
||||
widget.cursorModel.blockEvents = false;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final currentScreenSize = MediaQuery.of(context).size;
|
||||
if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) {
|
||||
_resetPosition();
|
||||
}
|
||||
_lastScreenSize = currentScreenSize;
|
||||
}
|
||||
|
||||
void _resetPosition() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
setState(() {
|
||||
_position = Offset(
|
||||
_kSpaceToHorizontalEdge + _joystickRadius,
|
||||
size.height * 0.5 + _joystickRadius * 1.5,
|
||||
);
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
Offset _offsetToPanDelta(Offset offset) {
|
||||
return Offset(
|
||||
offset.dx / _joystickRadius,
|
||||
offset.dy / _joystickRadius,
|
||||
);
|
||||
}
|
||||
|
||||
void _stopSendEventTimer() {
|
||||
_dragStartTimer?.cancel();
|
||||
_continuousMoveTimer?.cancel();
|
||||
_dragStartTimer = null;
|
||||
_continuousMoveTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return Positioned(child: Offstage());
|
||||
}
|
||||
return Positioned(
|
||||
left: _position.dx - _joystickRadius,
|
||||
top: _position.dy - _joystickRadius,
|
||||
child: GestureDetector(
|
||||
onPanStart: (details) {
|
||||
setState(() {
|
||||
_isPressed = true;
|
||||
});
|
||||
widget.cursorModel.blockEvents = true;
|
||||
_updateOffset(details.localPosition);
|
||||
|
||||
// 1. Send a single, small pan event immediately for responsiveness.
|
||||
// The movement is small for a gentle start.
|
||||
final initialDelta = _offsetToPanDelta(_offset);
|
||||
if (initialDelta.distance > 0) {
|
||||
widget.cursorModel.updatePan(initialDelta, Offset.zero, false);
|
||||
}
|
||||
|
||||
// 2. Start a one-shot timer to check if the user is holding for a drag.
|
||||
_dragStartTimer?.cancel();
|
||||
_dragStartTimer = Timer(const Duration(milliseconds: 120), () {
|
||||
// 3. If the timer fires, it's a drag. Start the continuous movement timer.
|
||||
_continuousMoveTimer?.cancel();
|
||||
_continuousMoveTimer =
|
||||
periodic_immediate(const Duration(milliseconds: 20), () async {
|
||||
if (_offset != Offset.zero) {
|
||||
widget.cursorModel.updatePan(
|
||||
_offsetToPanDelta(_offset) * _moveStep * _speed,
|
||||
Offset.zero,
|
||||
false);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
_updateOffset(details.localPosition);
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
setState(() {
|
||||
_offset = Offset.zero;
|
||||
_isPressed = false;
|
||||
});
|
||||
widget.cursorModel.blockEvents = false;
|
||||
|
||||
// 4. Critical step: On pan end, cancel all timers.
|
||||
// If it was a flick, this cancels the drag detection before it fires.
|
||||
// If it was a drag, this stops the continuous movement.
|
||||
_stopSendEventTimer();
|
||||
},
|
||||
child: CustomPaint(
|
||||
size: Size(_joystickRadius * 2, _joystickRadius * 2),
|
||||
painter: _JoystickPainter(
|
||||
_offset, _joystickRadius, _thumbRadius, _isPressed),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateOffset(Offset localPosition) {
|
||||
final center = Offset(_joystickRadius, _joystickRadius);
|
||||
final offset = localPosition - center;
|
||||
final distance = offset.distance;
|
||||
|
||||
if (distance <= _joystickRadius) {
|
||||
setState(() {
|
||||
_offset = offset;
|
||||
});
|
||||
} else {
|
||||
final clampedOffset = offset / distance * _joystickRadius;
|
||||
setState(() {
|
||||
_offset = clampedOffset;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _JoystickPainter extends CustomPainter {
|
||||
final Offset _offset;
|
||||
final double _joystickRadius;
|
||||
final double _thumbRadius;
|
||||
final bool _isPressed;
|
||||
|
||||
_JoystickPainter(
|
||||
this._offset, this._joystickRadius, this._thumbRadius, this._isPressed);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final joystickColor = _kDefaultColor;
|
||||
final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor;
|
||||
final thumbColor = _kWidgetHighlightColor;
|
||||
|
||||
final joystickPaint = Paint()
|
||||
..color = joystickColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final borderPaint = Paint()
|
||||
..color = borderColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
final thumbPaint = Paint()
|
||||
..color = thumbColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Draw joystick base and border
|
||||
canvas.drawCircle(center, _joystickRadius, joystickPaint);
|
||||
canvas.drawCircle(center, _joystickRadius, borderPaint);
|
||||
|
||||
// Draw thumb
|
||||
final thumbCenter = center + _offset;
|
||||
canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _JoystickPainter oldDelegate) {
|
||||
return oldDelegate._offset != _offset ||
|
||||
oldDelegate._isPressed != _isPressed;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
class GestureIcons {
|
||||
@@ -35,20 +36,27 @@ typedef OnTouchModeChange = void Function(bool);
|
||||
|
||||
class GestureHelp extends StatefulWidget {
|
||||
GestureHelp(
|
||||
{Key? key, required this.touchMode, required this.onTouchModeChange})
|
||||
{Key? key,
|
||||
required this.touchMode,
|
||||
required this.onTouchModeChange,
|
||||
required this.virtualMouseMode})
|
||||
: super(key: key);
|
||||
final bool touchMode;
|
||||
final OnTouchModeChange onTouchModeChange;
|
||||
final VirtualMouseMode virtualMouseMode;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _GestureHelpState(touchMode);
|
||||
State<StatefulWidget> createState() =>
|
||||
_GestureHelpState(touchMode, virtualMouseMode);
|
||||
}
|
||||
|
||||
class _GestureHelpState extends State<GestureHelp> {
|
||||
late int _selectedIndex;
|
||||
late bool _touchMode;
|
||||
final VirtualMouseMode _virtualMouseMode;
|
||||
|
||||
_GestureHelpState(bool touchMode) {
|
||||
_GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode)
|
||||
: _virtualMouseMode = virtualMouseMode {
|
||||
_touchMode = touchMode;
|
||||
_selectedIndex = _touchMode ? 1 : 0;
|
||||
}
|
||||
@@ -68,31 +76,144 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ToggleSwitch(
|
||||
initialLabelIndex: _selectedIndex,
|
||||
activeFgColor: Colors.white,
|
||||
inactiveFgColor: Colors.white60,
|
||||
activeBgColor: [MyTheme.accent],
|
||||
inactiveBgColor: Theme.of(context).hintColor,
|
||||
totalSwitches: 2,
|
||||
minWidth: 150,
|
||||
fontSize: 15,
|
||||
iconSize: 18,
|
||||
labels: [translate("Mouse mode"), translate("Touch mode")],
|
||||
icons: [Icons.mouse, Icons.touch_app],
|
||||
onToggle: (index) {
|
||||
setState(() {
|
||||
if (_selectedIndex != index) {
|
||||
_selectedIndex = index ?? 0;
|
||||
_touchMode = index == 0 ? false : true;
|
||||
widget.onTouchModeChange(_touchMode);
|
||||
}
|
||||
});
|
||||
},
|
||||
Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ToggleSwitch(
|
||||
initialLabelIndex: _selectedIndex,
|
||||
activeFgColor: Colors.white,
|
||||
inactiveFgColor: Colors.white60,
|
||||
activeBgColor: [MyTheme.accent],
|
||||
inactiveBgColor: Theme.of(context).hintColor,
|
||||
totalSwitches: 2,
|
||||
minWidth: 150,
|
||||
fontSize: 15,
|
||||
iconSize: 18,
|
||||
labels: [
|
||||
translate("Mouse mode"),
|
||||
translate("Touch mode")
|
||||
],
|
||||
icons: [Icons.mouse, Icons.touch_app],
|
||||
onToggle: (index) {
|
||||
setState(() {
|
||||
if (_selectedIndex != index) {
|
||||
_selectedIndex = index ?? 0;
|
||||
_touchMode = index == 0 ? false : true;
|
||||
widget.onTouchModeChange(_touchMode);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
Transform.translate(
|
||||
offset: const Offset(-10.0, 0.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _virtualMouseMode.showVirtualMouse,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(translate('Show virtual mouse')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_touchMode && _virtualMouseMode.showVirtualMouse)
|
||||
Padding(
|
||||
// Indent "Virtual mouse size"
|
||||
padding: const EdgeInsets.only(left: 24.0),
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 0.0, bottom: 0),
|
||||
child: Text(translate('Virtual mouse size')),
|
||||
),
|
||||
Transform.translate(
|
||||
offset: Offset(-0.0, -6.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 0.0),
|
||||
child: Text(translate('Small')),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _virtualMouseMode
|
||||
.virtualMouseScale,
|
||||
min: 0.8,
|
||||
max: 1.8,
|
||||
divisions: 10,
|
||||
onChanged: (value) {
|
||||
_virtualMouseMode
|
||||
.setVirtualMouseScale(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(right: 16.0),
|
||||
child: Text(translate('Large')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_touchMode && _virtualMouseMode.showVirtualMouse)
|
||||
Transform.translate(
|
||||
offset: const Offset(-10.0, -12.0),
|
||||
child: Padding(
|
||||
// Indent "Show virtual joystick"
|
||||
padding: const EdgeInsets.only(left: 24.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value:
|
||||
_virtualMouseMode.showVirtualJoystick,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(
|
||||
translate("Show virtual joystick")),
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
child: Wrap(
|
||||
spacing: space,
|
||||
|
||||
@@ -140,7 +140,7 @@ class AbModel {
|
||||
debugPrint("pull ab list");
|
||||
List<AbProfile> abProfiles = List.empty(growable: true);
|
||||
abProfiles.add(AbProfile(_personalAbGuid!, _personalAddressBookName,
|
||||
gFFI.userModel.userName.value, null, ShareRule.read.value));
|
||||
gFFI.userModel.userName.value, null, ShareRule.read.value, null));
|
||||
// get all address book name
|
||||
await _getSharedAbProfiles(abProfiles);
|
||||
addressbooks.removeWhere((key, value) =>
|
||||
@@ -208,7 +208,7 @@ class AbModel {
|
||||
return false;
|
||||
}
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
@@ -234,7 +234,7 @@ class AbModel {
|
||||
return false;
|
||||
}
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
@@ -271,7 +271,7 @@ class AbModel {
|
||||
headers['Content-Type'] = "application/json";
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
@@ -319,8 +319,8 @@ class AbModel {
|
||||
// #endregion
|
||||
|
||||
// #region peer
|
||||
Future<String?> addIdToCurrent(
|
||||
String id, String alias, String password, List<dynamic> tags) async {
|
||||
Future<String?> addIdToCurrent(String id, String alias, String password,
|
||||
List<dynamic> tags, String note) async {
|
||||
if (currentAbPeers.where((element) => element.id == id).isNotEmpty) {
|
||||
return "$id already exists in address book $_currentName";
|
||||
}
|
||||
@@ -333,6 +333,9 @@ class AbModel {
|
||||
if (password.isNotEmpty) {
|
||||
peer['password'] = password;
|
||||
}
|
||||
if (note.isNotEmpty) {
|
||||
peer['note'] = note;
|
||||
}
|
||||
final ret = await addPeersTo([peer], _currentName.value);
|
||||
_syncAllFromRecent = true;
|
||||
return ret;
|
||||
@@ -376,6 +379,14 @@ class AbModel {
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<bool> changeNote({required String id, required String note}) async {
|
||||
bool res = await current.changeNote(id: id, note: note);
|
||||
await pullNonLegacyAfterChange();
|
||||
currentAbPeers.refresh();
|
||||
// no need to save cache
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<bool> changePersonalHashPassword(String id, String hash) async {
|
||||
var ret = false;
|
||||
final personalAb = addressbooks[_personalAddressBookName];
|
||||
@@ -609,7 +620,7 @@ class AbModel {
|
||||
if (name == null || guid == null) {
|
||||
continue;
|
||||
}
|
||||
ab = Ab(AbProfile(guid, name, '', '', ShareRule.read.value),
|
||||
ab = Ab(AbProfile(guid, name, '', '', ShareRule.read.value, null),
|
||||
name == _personalAddressBookName);
|
||||
}
|
||||
addressbooks[name] = ab;
|
||||
@@ -658,6 +669,15 @@ class AbModel {
|
||||
}
|
||||
}
|
||||
|
||||
String getPeerNote(String id) {
|
||||
final it = currentAbPeers.where((p0) => p0.id == id);
|
||||
if (it.isEmpty) {
|
||||
return '';
|
||||
} else {
|
||||
return it.first.note;
|
||||
}
|
||||
}
|
||||
|
||||
Color getCurrentAbTagColor(String tag) {
|
||||
if (tag == kUntagged) {
|
||||
return MyTheme.accent;
|
||||
@@ -767,6 +787,28 @@ class AbModel {
|
||||
_peerIdUpdateListeners.remove(key);
|
||||
}
|
||||
|
||||
String? getdefaultSharedPassword() {
|
||||
if (current.isPersonal()) {
|
||||
return null;
|
||||
}
|
||||
final profile = current.sharedProfile();
|
||||
if (profile == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (profile.info is Map) {
|
||||
final password = (profile.info as Map)['password'];
|
||||
if (password is String && password.isNotEmpty) {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint("getdefaultSharedPassword: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
}
|
||||
|
||||
@@ -841,6 +883,8 @@ abstract class BaseAb {
|
||||
|
||||
Future<bool> changeAlias({required String id, required String alias});
|
||||
|
||||
Future<bool> changeNote({required String id, required String note});
|
||||
|
||||
Future<bool> changePersonalHashPassword(String id, String hash);
|
||||
|
||||
Future<bool> changeSharedPassword(String id, String password);
|
||||
@@ -925,7 +969,7 @@ class LegacyAb extends BaseAb {
|
||||
peers.clear();
|
||||
} else if (resp.body.isNotEmpty) {
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
} else if (json.containsKey('data')) {
|
||||
@@ -983,7 +1027,7 @@ class LegacyAb extends BaseAb {
|
||||
ret = true;
|
||||
} else {
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
} else if (resp.statusCode == 200) {
|
||||
@@ -1068,6 +1112,12 @@ class LegacyAb extends BaseAb {
|
||||
return await pushAb();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> changeNote({required String id, required String note}) async {
|
||||
// no need to implement
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> changeSharedPassword(String id, String password) async {
|
||||
// no need to implement
|
||||
@@ -1359,7 +1409,7 @@ class Ab extends BaseAb {
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
@@ -1416,7 +1466,7 @@ class Ab extends BaseAb {
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
statusCode = resp.statusCode;
|
||||
List<dynamic> json =
|
||||
_jsonDecodeRespList(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeRespList(decode_http_response(resp), resp.statusCode);
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
}
|
||||
@@ -1527,6 +1577,27 @@ class Ab extends BaseAb {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> changeNote({required String id, required String note}) async {
|
||||
try {
|
||||
final api =
|
||||
"${await bind.mainGetApiServer()}/api/ab/peer/update/${profile.guid}";
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
final body = jsonEncode({"id": id, "note": note});
|
||||
final resp = await http.put(Uri.parse(api), headers: headers, body: body);
|
||||
final errMsg = _jsonDecodeActionResp(resp);
|
||||
if (errMsg.isNotEmpty) {
|
||||
BotToast.showText(contentColor: Colors.red, text: errMsg);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('changeNote err: ${err.toString()}');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _setPassword(Object bodyContent) async {
|
||||
try {
|
||||
final api =
|
||||
@@ -1793,6 +1864,11 @@ class DummyAb extends BaseAb {
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> changeNote({required String id, required String note}) async {
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> changePersonalHashPassword(String id, String hash) async {
|
||||
return false;
|
||||
|
||||
@@ -30,15 +30,17 @@ enum SortBy {
|
||||
class JobID {
|
||||
int _count = 0;
|
||||
int next() {
|
||||
String v = bind.mainGetCommonSync(key: 'transfer-job-id');
|
||||
try {
|
||||
return int.parse(v);
|
||||
if (!isWeb) {
|
||||
String v = bind.mainGetCommonSync(key: 'transfer-job-id');
|
||||
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;
|
||||
debugPrint("Failed to get transfer job id: $e");
|
||||
}
|
||||
// Finally increase the count if on the web or if failed to get the id.
|
||||
_count++;
|
||||
return _count;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ class GroupModel {
|
||||
final resp = await http.get(uri, headers: getHttpHeaders());
|
||||
_statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeResp(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeResp(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
@@ -180,7 +180,7 @@ class GroupModel {
|
||||
final resp = await http.get(uri, headers: getHttpHeaders());
|
||||
_statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeResp(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeResp(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
if (json['error'] == 'Admin required!' ||
|
||||
json['error']
|
||||
@@ -246,7 +246,7 @@ class GroupModel {
|
||||
_statusCode = resp.statusCode;
|
||||
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeResp(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
_jsonDecodeResp(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
|
||||
@@ -371,6 +371,7 @@ class InputModel {
|
||||
String get id => parent.target?.id ?? '';
|
||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
||||
bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
|
||||
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
||||
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
||||
int get trackpadSpeed => _trackpadSpeed;
|
||||
@@ -765,6 +766,11 @@ class InputModel {
|
||||
command: command);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> getMouseEventMove() => {
|
||||
'type': _kMouseEventMove,
|
||||
'buttons': 0,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
|
||||
final Map<String, dynamic> out = {};
|
||||
|
||||
@@ -876,7 +882,7 @@ class InputModel {
|
||||
|
||||
void onPointHoverImage(PointerHoverEvent e) {
|
||||
_stopFling = true;
|
||||
if (isViewOnly) return;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (!isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = true;
|
||||
@@ -1037,7 +1043,7 @@ class InputModel {
|
||||
if (isDesktop) _queryOtherWindowCoords = true;
|
||||
_remoteWindowCoords = [];
|
||||
_windowRect = null;
|
||||
if (isViewOnly) return;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
if (isPhysicalMouse.value) {
|
||||
@@ -1051,7 +1057,7 @@ class InputModel {
|
||||
|
||||
void onPointUpImage(PointerUpEvent e) {
|
||||
if (isDesktop) _queryOtherWindowCoords = false;
|
||||
if (isViewOnly) return;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (isPhysicalMouse.value) {
|
||||
@@ -1060,7 +1066,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
void onPointMoveImage(PointerMoveEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (_queryOtherWindowCoords) {
|
||||
@@ -1221,16 +1227,17 @@ class InputModel {
|
||||
return false;
|
||||
}
|
||||
|
||||
void handleMouse(
|
||||
Map<String, dynamic>? processEventToPeer(
|
||||
Map<String, dynamic> evt,
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
bool moveCanvas = true,
|
||||
}) {
|
||||
if (isViewCamera) return;
|
||||
if (isViewCamera) return null;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
if (_checkPeerControlProtected(x, y)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
var type = kMouseEventTypeDefault;
|
||||
@@ -1247,7 +1254,7 @@ class InputModel {
|
||||
isMove = true;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
evt['type'] = type;
|
||||
|
||||
@@ -1265,9 +1272,10 @@ class InputModel {
|
||||
type,
|
||||
onExit: onExit,
|
||||
buttons: evt['buttons'],
|
||||
moveCanvas: moveCanvas,
|
||||
);
|
||||
if (pos == null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (type != '') {
|
||||
evt['x'] = '0';
|
||||
@@ -1285,7 +1293,22 @@ class InputModel {
|
||||
kForwardMouseButton: 'forward'
|
||||
};
|
||||
evt['buttons'] = mapButtons[evt['buttons']] ?? '';
|
||||
bind.sessionSendMouse(sessionId: sessionId, msg: json.encode(modify(evt)));
|
||||
return evt;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? handleMouse(
|
||||
Map<String, dynamic> evt,
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
bool moveCanvas = true,
|
||||
}) {
|
||||
final evtToPeer =
|
||||
processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas);
|
||||
if (evtToPeer != null) {
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
|
||||
}
|
||||
return evtToPeer;
|
||||
}
|
||||
|
||||
Point? handlePointerDevicePos(
|
||||
@@ -1296,6 +1319,7 @@ class InputModel {
|
||||
String evtType, {
|
||||
bool onExit = false,
|
||||
int buttons = kPrimaryMouseButton,
|
||||
bool moveCanvas = true,
|
||||
}) {
|
||||
final ffiModel = parent.target!.ffiModel;
|
||||
CanvasCoords canvas =
|
||||
@@ -1312,15 +1336,19 @@ class InputModel {
|
||||
isMove = false;
|
||||
canvas = coords.canvas;
|
||||
rect = coords.remoteRect;
|
||||
x -= coords.relativeOffset.dx / devicePixelRatio;
|
||||
y -= coords.relativeOffset.dy / devicePixelRatio;
|
||||
x -= isWindows
|
||||
? coords.relativeOffset.dx / devicePixelRatio
|
||||
: coords.relativeOffset.dx;
|
||||
y -= isWindows
|
||||
? coords.relativeOffset.dy / devicePixelRatio
|
||||
: coords.relativeOffset.dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
y -= CanvasModel.topToEdge;
|
||||
x -= CanvasModel.leftToEdge;
|
||||
if (isMove) {
|
||||
if (isMove && moveCanvas) {
|
||||
parent.target!.canvasModel.moveDesktopMouse(x, y);
|
||||
}
|
||||
|
||||
@@ -1338,15 +1366,21 @@ class InputModel {
|
||||
}
|
||||
|
||||
bool _isInCurrentWindow(double x, double y) {
|
||||
final w = _windowRect!.width / devicePixelRatio;
|
||||
final h = _windowRect!.width / devicePixelRatio;
|
||||
var w = _windowRect!.width;
|
||||
var h = _windowRect!.height;
|
||||
if (isWindows) {
|
||||
w /= devicePixelRatio;
|
||||
h /= devicePixelRatio;
|
||||
}
|
||||
return x >= 0 && y >= 0 && x <= w && y <= h;
|
||||
}
|
||||
|
||||
static RemoteWindowCoords? findRemoteCoords(double x, double y,
|
||||
List<RemoteWindowCoords> remoteWindowCoords, double devicePixelRatio) {
|
||||
x *= devicePixelRatio;
|
||||
y *= devicePixelRatio;
|
||||
if (isWindows) {
|
||||
x *= devicePixelRatio;
|
||||
y *= devicePixelRatio;
|
||||
}
|
||||
for (final c in remoteWindowCoords) {
|
||||
if (x >= c.relativeOffset.dx &&
|
||||
y >= c.relativeOffset.dy &&
|
||||
|
||||
@@ -42,6 +42,7 @@ import '../utils/image.dart' as img;
|
||||
import '../common/widgets/dialog.dart';
|
||||
import 'input_model.dart';
|
||||
import 'platform_model.dart';
|
||||
import 'package:flutter_hbb/utils/scale.dart';
|
||||
|
||||
import 'package:flutter_hbb/generated_bridge.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/bridge.dart';
|
||||
@@ -61,6 +62,7 @@ class CachedPeerData {
|
||||
|
||||
bool secure = false;
|
||||
bool direct = false;
|
||||
String streamType = '';
|
||||
|
||||
CachedPeerData();
|
||||
|
||||
@@ -74,6 +76,7 @@ class CachedPeerData {
|
||||
'permissions': permissions,
|
||||
'secure': secure,
|
||||
'direct': direct,
|
||||
'streamType': streamType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +95,7 @@ class CachedPeerData {
|
||||
});
|
||||
data.secure = map['secure'];
|
||||
data.direct = map['direct'];
|
||||
data.streamType = map['streamType'];
|
||||
return data;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse CachedPeerData: $e');
|
||||
@@ -110,9 +114,11 @@ class FfiModel with ChangeNotifier {
|
||||
bool? _secure;
|
||||
bool? _direct;
|
||||
bool _touchMode = false;
|
||||
late VirtualMouseMode virtualMouseMode;
|
||||
Timer? _timer;
|
||||
var _reconnects = 1;
|
||||
bool _viewOnly = false;
|
||||
bool _showMyCursor = false;
|
||||
WeakReference<FFI> parent;
|
||||
late final SessionID sessionId;
|
||||
|
||||
@@ -151,6 +157,7 @@ class FfiModel with ChangeNotifier {
|
||||
bool get isPeerMobile => isPeerAndroid;
|
||||
|
||||
bool get viewOnly => _viewOnly;
|
||||
bool get showMyCursor => _showMyCursor;
|
||||
|
||||
set inputBlocked(v) {
|
||||
_inputBlocked = v;
|
||||
@@ -160,6 +167,7 @@ class FfiModel with ChangeNotifier {
|
||||
clear();
|
||||
sessionId = parent.target!.sessionId;
|
||||
cachedPeerData.permissions = _permissions;
|
||||
virtualMouseMode = VirtualMouseMode(this);
|
||||
}
|
||||
|
||||
Rect? globalDisplaysRect() => _getDisplaysRect(_pi.displays, true);
|
||||
@@ -223,27 +231,45 @@ class FfiModel with ChangeNotifier {
|
||||
timerScreenshot?.cancel();
|
||||
}
|
||||
|
||||
setConnectionType(String peerId, bool secure, bool direct) {
|
||||
setConnectionType(
|
||||
String peerId, bool secure, bool direct, String streamType) {
|
||||
cachedPeerData.secure = secure;
|
||||
cachedPeerData.direct = direct;
|
||||
cachedPeerData.streamType = streamType;
|
||||
_secure = secure;
|
||||
_direct = direct;
|
||||
try {
|
||||
var connectionType = ConnectionTypeState.find(peerId);
|
||||
connectionType.setSecure(secure);
|
||||
connectionType.setDirect(direct);
|
||||
connectionType.setStreamType(streamType);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
Widget? getConnectionImage() {
|
||||
Widget? getConnectionImageText() {
|
||||
if (secure == null || direct == null) {
|
||||
return null;
|
||||
} else {
|
||||
final icon =
|
||||
'${secure == true ? 'secure' : 'insecure'}${direct == true ? '' : '_relay'}';
|
||||
return SvgPicture.asset('assets/$icon.svg', width: 48, height: 48);
|
||||
final iconWidget =
|
||||
SvgPicture.asset('assets/$icon.svg', width: 48, height: 48);
|
||||
String connectionText =
|
||||
getConnectionText(secure!, direct!, cachedPeerData.streamType);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
iconWidget,
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
connectionText,
|
||||
style: TextStyle(fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +286,7 @@ class FfiModel with ChangeNotifier {
|
||||
'link': '',
|
||||
}, sessionId, peerId);
|
||||
updatePrivacyMode(data.updatePrivacyMode, sessionId, peerId);
|
||||
setConnectionType(peerId, data.secure, data.direct);
|
||||
setConnectionType(peerId, data.secure, data.direct, data.streamType);
|
||||
await handlePeerInfo(data.peerInfo, peerId, true);
|
||||
for (final element in data.cursorDataList) {
|
||||
updateLastCursorId(element);
|
||||
@@ -289,8 +315,8 @@ class FfiModel with ChangeNotifier {
|
||||
} else if (name == 'sync_platform_additions') {
|
||||
handlePlatformAdditions(evt, sessionId, peerId);
|
||||
} else if (name == 'connection_ready') {
|
||||
setConnectionType(
|
||||
peerId, evt['secure'] == 'true', evt['direct'] == 'true');
|
||||
setConnectionType(peerId, evt['secure'] == 'true',
|
||||
evt['direct'] == 'true', evt['stream_type'] ?? '');
|
||||
} else if (name == 'switch_display') {
|
||||
// switch display is kept for backward compatibility
|
||||
handleSwitchDisplay(evt, sessionId, peerId);
|
||||
@@ -1081,9 +1107,23 @@ class FfiModel with ChangeNotifier {
|
||||
if (isPeerAndroid) {
|
||||
_touchMode = true;
|
||||
} else {
|
||||
_touchMode = await bind.sessionGetOption(
|
||||
sessionId: sessionId, arg: kOptionTouchMode) !=
|
||||
'';
|
||||
// `kOptionTouchMode` is originally peer option, but it is moved to local option later.
|
||||
// We check local option first, if not set, then check peer option.
|
||||
// Because if local option is not empty:
|
||||
// 1. User has set the touch mode explicitly.
|
||||
// 2. The advanced option (custom client) is set.
|
||||
// Then we choose to use the local option.
|
||||
final optLocal = bind.mainGetLocalOption(key: kOptionTouchMode);
|
||||
if (optLocal != '') {
|
||||
_touchMode = optLocal == 'Y';
|
||||
} else {
|
||||
final optSession = await bind.sessionGetOption(
|
||||
sessionId: sessionId, arg: kOptionTouchMode);
|
||||
_touchMode = optSession != '';
|
||||
}
|
||||
}
|
||||
if (isMobile) {
|
||||
virtualMouseMode.loadOptions();
|
||||
}
|
||||
if (connType == ConnType.fileTransfer) {
|
||||
parent.target?.fileModel.onReady();
|
||||
@@ -1123,6 +1163,8 @@ class FfiModel with ChangeNotifier {
|
||||
peerId,
|
||||
bind.sessionGetToggleOptionSync(
|
||||
sessionId: sessionId, arg: kOptionToggleViewOnly));
|
||||
setShowMyCursor(bind.sessionGetToggleOptionSync(
|
||||
sessionId: sessionId, arg: kOptionToggleShowMyCursor));
|
||||
}
|
||||
if (connType == ConnType.defaultConn || connType == ConnType.viewCamera) {
|
||||
final platformAdditions = evt['platform_additions'];
|
||||
@@ -1473,6 +1515,79 @@ class FfiModel with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void setShowMyCursor(bool value) {
|
||||
if (_showMyCursor != value) {
|
||||
_showMyCursor = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VirtualMouseMode with ChangeNotifier {
|
||||
bool _showVirtualMouse = false;
|
||||
double _virtualMouseScale = 1.0;
|
||||
bool _showVirtualJoystick = false;
|
||||
|
||||
bool get showVirtualMouse => _showVirtualMouse;
|
||||
double get virtualMouseScale => _virtualMouseScale;
|
||||
bool get showVirtualJoystick => _showVirtualJoystick;
|
||||
|
||||
FfiModel ffiModel;
|
||||
|
||||
VirtualMouseMode(this.ffiModel);
|
||||
|
||||
bool _shouldShow() => !ffiModel.isPeerAndroid;
|
||||
|
||||
setShowVirtualMouse(bool b) {
|
||||
if (b == _showVirtualMouse) return;
|
||||
if (_shouldShow()) {
|
||||
_showVirtualMouse = b;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
setVirtualMouseScale(double s) {
|
||||
if (s <= 0) return;
|
||||
if (s == _virtualMouseScale) return;
|
||||
_virtualMouseScale = s;
|
||||
bind.mainSetLocalOption(key: kOptionVirtualMouseScale, value: s.toString());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
setShowVirtualJoystick(bool b) {
|
||||
if (b == _showVirtualJoystick) return;
|
||||
if (_shouldShow()) {
|
||||
_showVirtualJoystick = b;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void loadOptions() {
|
||||
_showVirtualMouse =
|
||||
bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y';
|
||||
_virtualMouseScale = double.tryParse(
|
||||
bind.mainGetLocalOption(key: kOptionVirtualMouseScale)) ??
|
||||
1.0;
|
||||
_showVirtualJoystick =
|
||||
bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleVirtualMouse() async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionShowVirtualMouse, value: showVirtualMouse ? 'N' : 'Y');
|
||||
setShowVirtualMouse(
|
||||
bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y');
|
||||
}
|
||||
|
||||
Future<void> toggleVirtualJoystick() async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionShowVirtualJoystick,
|
||||
value: showVirtualJoystick ? 'N' : 'Y');
|
||||
setShowVirtualJoystick(
|
||||
bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y');
|
||||
}
|
||||
}
|
||||
|
||||
class ImageModel with ChangeNotifier {
|
||||
@@ -1667,6 +1782,8 @@ class ViewStyle {
|
||||
final s2 = height / displayHeight;
|
||||
s = s1 < s2 ? s1 : s2;
|
||||
}
|
||||
} else if (style == kRemoteViewStyleCustom) {
|
||||
// Custom scale is session-scoped and applied in CanvasModel.updateViewStyle()
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -1783,7 +1900,13 @@ class CanvasModel with ChangeNotifier {
|
||||
displayWidth: displayWidth,
|
||||
displayHeight: displayHeight,
|
||||
);
|
||||
if (_lastViewStyle == viewStyle) {
|
||||
// If only the Custom scale percent changed, proceed to update even if
|
||||
// the basic ViewStyle fields are equal.
|
||||
// In Custom scale mode, the scale percent can change independently of the other
|
||||
// ViewStyle fields and is not captured by the equality check. Therefore, we must
|
||||
// allow updates to proceed when style == kRemoteViewStyleCustom, even if the
|
||||
// rest of the ViewStyle fields are unchanged.
|
||||
if (_lastViewStyle == viewStyle && style != kRemoteViewStyleCustom) {
|
||||
return;
|
||||
}
|
||||
if (_lastViewStyle.style != viewStyle.style) {
|
||||
@@ -1792,12 +1915,30 @@ class CanvasModel with ChangeNotifier {
|
||||
_lastViewStyle = viewStyle;
|
||||
_scale = viewStyle.scale;
|
||||
|
||||
// Apply custom scale percent when in Custom mode
|
||||
if (style == kRemoteViewStyleCustom) {
|
||||
try {
|
||||
_scale = await getSessionCustomScale(sessionId);
|
||||
} catch (e, stack) {
|
||||
debugPrint('Error in getSessionCustomScale: $e');
|
||||
debugPrintStack(stackTrace: stack);
|
||||
_scale = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
_devicePixelRatio = ui.window.devicePixelRatio;
|
||||
if (kIgnoreDpi && style == kRemoteViewStyleOriginal) {
|
||||
_scale = 1.0 / _devicePixelRatio;
|
||||
if (kIgnoreDpi) {
|
||||
if (style == kRemoteViewStyleOriginal) {
|
||||
_scale = 1.0 / _devicePixelRatio;
|
||||
} else if (_scale != 0 && style == kRemoteViewStyleCustom) {
|
||||
_scale /= _devicePixelRatio;
|
||||
}
|
||||
}
|
||||
_resetCanvasOffset(displayWidth, displayHeight);
|
||||
_imageOverflow.value = _x < 0 || y < 0;
|
||||
final overflow = _x < 0 || y < 0;
|
||||
if (_imageOverflow.value != overflow) {
|
||||
_imageOverflow.value = overflow;
|
||||
}
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -1818,7 +1959,7 @@ class CanvasModel with ChangeNotifier {
|
||||
tryUpdateScrollStyle(Duration duration, String? style) async {
|
||||
if (_scrollStyle != ScrollStyle.scrollbar) return;
|
||||
style ??= await bind.sessionGetViewStyle(sessionId: sessionId);
|
||||
if (style != kRemoteViewStyleOriginal) {
|
||||
if (style != kRemoteViewStyleOriginal && style != kRemoteViewStyleCustom) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2234,9 +2375,25 @@ class CursorModel with ChangeNotifier {
|
||||
|
||||
Rect? get keyHelpToolsRectToAdjustCanvas =>
|
||||
_lastKeyboardIsVisible ? _keyHelpToolsRect : null;
|
||||
keyHelpToolsVisibilityChanged(Rect? r, bool keyboardIsVisible) {
|
||||
_keyHelpToolsRect = r;
|
||||
if (r == null) {
|
||||
// The blocked rect is used to block the pointer/touch events in the remote page.
|
||||
final List<Rect> _blockedRects = [];
|
||||
// Used in shouldBlock().
|
||||
// _blockEvents is a flag to block pointer/touch events on the remote image.
|
||||
// It is set to true to prevent accidental touch events in the following scenarios:
|
||||
// 1. In floating mouse mode, when the scroll circle is shown.
|
||||
// 2. In floating mouse widgets mode, when the left/right buttons are moving.
|
||||
// 3. In floating mouse widgets mode, when using the virtual joystick.
|
||||
// When _blockEvents is true, all pointer/touch events are blocked regardless of the contents of _blockedRects.
|
||||
// _blockedRects contains specific rectangular regions where events are blocked; these are checked when _blockEvents is false.
|
||||
// In summary: _blockEvents acts as a global block, while _blockedRects provides fine-grained blocking.
|
||||
bool _blockEvents = false;
|
||||
List<Rect> get blockedRects => List.unmodifiable(_blockedRects);
|
||||
|
||||
set blockEvents(bool v) => _blockEvents = v;
|
||||
|
||||
keyHelpToolsVisibilityChanged(Rect? rect, bool keyboardIsVisible) {
|
||||
_keyHelpToolsRect = rect;
|
||||
if (rect == null) {
|
||||
_lastIsBlocked = false;
|
||||
} else {
|
||||
// Block the touch event is safe here.
|
||||
@@ -2251,6 +2408,14 @@ class CursorModel with ChangeNotifier {
|
||||
_lastKeyboardIsVisible = keyboardIsVisible;
|
||||
}
|
||||
|
||||
addBlockedRect(Rect rect) {
|
||||
_blockedRects.add(rect);
|
||||
}
|
||||
|
||||
removeBlockedRect(Rect rect) {
|
||||
_blockedRects.remove(rect);
|
||||
}
|
||||
|
||||
get lastIsBlocked => _lastIsBlocked;
|
||||
|
||||
ui.Image? get image => _image;
|
||||
@@ -2317,13 +2482,22 @@ class CursorModel with ChangeNotifier {
|
||||
|
||||
// mobile Soft keyboard, block touch event from the KeyHelpTools
|
||||
shouldBlock(double x, double y) {
|
||||
if (_blockEvents) {
|
||||
return true;
|
||||
}
|
||||
final offset = Offset(x, y);
|
||||
for (final rect in _blockedRects) {
|
||||
if (isPointInRect(offset, rect)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For help tools rectangle, only block touch event when in touch mode.
|
||||
if (!(parent.target?.ffiModel.touchMode ?? false)) {
|
||||
return false;
|
||||
}
|
||||
if (_keyHelpToolsRect == null) {
|
||||
return false;
|
||||
}
|
||||
if (isPointInRect(Offset(x, y), _keyHelpToolsRect!)) {
|
||||
if (_keyHelpToolsRect != null &&
|
||||
isPointInRect(offset, _keyHelpToolsRect!)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -2343,6 +2517,10 @@ class CursorModel with ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> syncCursorPosition() async {
|
||||
await parent.target?.inputModel.moveMouse(_x, _y);
|
||||
}
|
||||
|
||||
bool isInRemoteRect(Offset offset) {
|
||||
return getRemotePosInRect(offset) != null;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,10 @@ class PlatformFFI {
|
||||
// only support for android
|
||||
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
|
||||
} else if (isIOS) {
|
||||
_homeDir = _ffiBind.mainGetDataDirIos();
|
||||
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
||||
// which provided the `downloads` path in the sandbox.
|
||||
// It is unclear why we now use the `data` directory in the sandbox instead.
|
||||
_homeDir = _ffiBind.mainGetDataDirIos(appDir: _dir);
|
||||
} else {
|
||||
// no need to set home dir
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class Peer {
|
||||
bool online = false;
|
||||
String loginName; //login username
|
||||
String device_group_name;
|
||||
String note;
|
||||
bool? sameServer;
|
||||
|
||||
String getId() {
|
||||
@@ -43,6 +44,7 @@ class Peer {
|
||||
rdpUsername = json['rdpUsername'] ?? '',
|
||||
loginName = json['loginName'] ?? '',
|
||||
device_group_name = json['device_group_name'] ?? '',
|
||||
note = json['note'] is String ? json['note'] : '',
|
||||
sameServer = json['same_server'];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -60,6 +62,7 @@ class Peer {
|
||||
"rdpUsername": rdpUsername,
|
||||
'loginName': loginName,
|
||||
'device_group_name': device_group_name,
|
||||
'note': note,
|
||||
'same_server': sameServer,
|
||||
};
|
||||
}
|
||||
@@ -104,6 +107,7 @@ class Peer {
|
||||
required this.rdpUsername,
|
||||
required this.loginName,
|
||||
required this.device_group_name,
|
||||
required this.note,
|
||||
this.sameServer,
|
||||
});
|
||||
|
||||
@@ -122,6 +126,7 @@ class Peer {
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
note: '',
|
||||
);
|
||||
bool equal(Peer other) {
|
||||
return id == other.id &&
|
||||
@@ -136,7 +141,8 @@ class Peer {
|
||||
rdpPort == other.rdpPort &&
|
||||
rdpUsername == other.rdpUsername &&
|
||||
device_group_name == other.device_group_name &&
|
||||
loginName == other.loginName;
|
||||
loginName == other.loginName &&
|
||||
note == other.note;
|
||||
}
|
||||
|
||||
Peer.copy(Peer other)
|
||||
@@ -154,6 +160,7 @@ class Peer {
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class PeerTabModel with ChangeNotifier {
|
||||
List<bool> isEnabled = List.from([
|
||||
true,
|
||||
true,
|
||||
!isWeb,
|
||||
!isWeb && bind.mainGetLocalOption(key: "disable-discovery-panel") != "Y",
|
||||
!(bind.isDisableAb() || bind.isDisableAccount()),
|
||||
!(bind.isDisableGroupPanel() || bind.isDisableAccount()),
|
||||
]);
|
||||
|
||||
@@ -304,14 +304,14 @@ class TerminalModel with ChangeNotifier {
|
||||
// Try to decode as base64 first
|
||||
try {
|
||||
final bytes = base64Decode(data);
|
||||
text = utf8.decode(bytes);
|
||||
text = utf8.decode(bytes, allowMalformed: true);
|
||||
} catch (e) {
|
||||
// If base64 decode fails, treat as plain text
|
||||
text = data;
|
||||
}
|
||||
} else if (data is List) {
|
||||
// Handle if data comes as byte array
|
||||
text = utf8.decode(List<int>.from(data));
|
||||
text = utf8.decode(List<int>.from(data), allowMalformed: true);
|
||||
} else {
|
||||
debugPrint('[TerminalModel] Unknown data type: ${data.runtimeType}');
|
||||
return;
|
||||
|
||||
@@ -66,7 +66,7 @@ class UserModel {
|
||||
reset(resetOther: status == 401);
|
||||
return;
|
||||
}
|
||||
final data = json.decode(utf8.decode(response.bodyBytes));
|
||||
final data = json.decode(decode_http_response(response));
|
||||
final error = data['error'];
|
||||
if (error != null) {
|
||||
throw error;
|
||||
@@ -160,7 +160,7 @@ class UserModel {
|
||||
|
||||
final Map<String, dynamic> body;
|
||||
try {
|
||||
body = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
body = jsonDecode(decode_http_response(resp));
|
||||
} catch (e) {
|
||||
debugPrint("login: jsonDecode resp body failed: ${e.toString()}");
|
||||
if (resp.statusCode != 200) {
|
||||
|
||||
34
flutter/lib/utils/scale.dart
Normal file
34
flutter/lib/utils/scale.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Clamp custom scale percent to supported bounds.
|
||||
/// Keep this in sync with the slider's minimum in the desktop toolbar UI.
|
||||
///
|
||||
/// This function exists to ensure consistent clamping behavior across the app
|
||||
/// and to provide a single point of reference for the valid scale range.
|
||||
int clampCustomScalePercent(int percent) {
|
||||
return percent.clamp(kScaleCustomMinPercent, kScaleCustomMaxPercent);
|
||||
}
|
||||
|
||||
/// Parse a string percent and clamp. Defaults to 100 when invalid.
|
||||
int parseCustomScalePercent(String? s, {int defaultPercent = 100}) {
|
||||
final parsed = int.tryParse(s ?? '') ?? defaultPercent;
|
||||
return clampCustomScalePercent(parsed);
|
||||
}
|
||||
|
||||
/// Convert a percent value to scale factor after clamping.
|
||||
double percentToScale(int percent) => clampCustomScalePercent(percent) / 100.0;
|
||||
|
||||
/// Fetch, parse and clamp the custom scale percent for a session.
|
||||
Future<int> getSessionCustomScalePercent(UuidValue sessionId) async {
|
||||
final opt = await bind.sessionGetFlutterOption(
|
||||
sessionId: sessionId, k: kCustomScalePercentKey);
|
||||
return parseCustomScalePercent(opt);
|
||||
}
|
||||
|
||||
/// Fetch and compute the custom scale factor for a session.
|
||||
Future<double> getSessionCustomScale(UuidValue sessionId) async {
|
||||
final p = await getSessionCustomScalePercent(sessionId);
|
||||
return percentToScale(p);
|
||||
}
|
||||
@@ -433,7 +433,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = HZF9JMC8YN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -579,7 +579,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = HZF9JMC8YN;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -609,7 +609,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = HZF9JMC8YN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.1+59
|
||||
version: 1.4.3+60
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
|
||||
@@ -10,6 +10,7 @@ add_executable(${BINARY_NAME} WIN32
|
||||
"flutter_window.cpp"
|
||||
"main.cpp"
|
||||
"utils.cpp"
|
||||
"win32_desktop.cpp"
|
||||
"win32_window.cpp"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
"Runner.rc"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
|
||||
#include "win32_desktop.h"
|
||||
#include "flutter_window.h"
|
||||
#include "utils.h"
|
||||
|
||||
@@ -126,8 +127,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
|
||||
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(800, 600);
|
||||
|
||||
// Get primary monitor's work area.
|
||||
Win32Window::Point workarea_origin(0, 0);
|
||||
Win32Window::Size workarea_size(0, 0);
|
||||
|
||||
Win32Desktop::GetWorkArea(workarea_origin, workarea_size);
|
||||
|
||||
// Compute window bounds for default main window position: (10, 10) x(800, 600)
|
||||
Win32Window::Point relative_origin(10, 10);
|
||||
|
||||
Win32Window::Point origin(workarea_origin.x + relative_origin.x, workarea_origin.y + relative_origin.y);
|
||||
Win32Window::Size size(800u, 600u);
|
||||
|
||||
// Fit the window to the monitor's work area.
|
||||
Win32Desktop::FitToWorkArea(origin, size);
|
||||
|
||||
std::wstring window_title;
|
||||
if (is_cm_page) {
|
||||
window_title = app_name + L" - Connection Manager";
|
||||
|
||||
69
flutter/windows/runner/win32_desktop.cpp
Normal file
69
flutter/windows/runner/win32_desktop.cpp
Normal file
@@ -0,0 +1,69 @@
|
||||
#include "win32_desktop.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace Win32Desktop
|
||||
{
|
||||
void GetWorkArea(Win32Window::Point& origin, Win32Window::Size& size)
|
||||
{
|
||||
RECT windowRect;
|
||||
|
||||
windowRect.left = origin.x;
|
||||
windowRect.top = origin.y;
|
||||
windowRect.right = origin.x + size.width;
|
||||
windowRect.bottom = origin.y + size.height;
|
||||
|
||||
HMONITOR hMonitor = MonitorFromRect(&windowRect, MONITOR_DEFAULTTONEAREST);
|
||||
|
||||
if (hMonitor == NULL)
|
||||
hMonitor = MonitorFromWindow(NULL, MONITOR_DEFAULTTOPRIMARY);
|
||||
|
||||
RECT workAreaRect;
|
||||
workAreaRect.left = 0;
|
||||
workAreaRect.top = 0;
|
||||
workAreaRect.right = 1280;
|
||||
workAreaRect.bottom = 1024 - 40; // default Windows 10 task bar height
|
||||
|
||||
if (hMonitor != NULL)
|
||||
{
|
||||
MONITORINFO monitorInfo = {0};
|
||||
|
||||
monitorInfo.cbSize = sizeof(monitorInfo);
|
||||
|
||||
if (GetMonitorInfoW(hMonitor, &monitorInfo))
|
||||
{
|
||||
workAreaRect = monitorInfo.rcWork;
|
||||
}
|
||||
}
|
||||
|
||||
origin.x = workAreaRect.left;
|
||||
origin.y = workAreaRect.top;
|
||||
|
||||
size.width = workAreaRect.right - workAreaRect.left;
|
||||
size.height = workAreaRect.bottom - workAreaRect.top;
|
||||
}
|
||||
|
||||
void FitToWorkArea(Win32Window::Point& origin, Win32Window::Size& size)
|
||||
{
|
||||
// Retrieve the work area of the monitor that contains or
|
||||
// is closed to the supplied window bounds.
|
||||
Win32Window::Point workarea_origin = origin;
|
||||
Win32Window::Size workarea_size = size;
|
||||
|
||||
GetWorkArea(workarea_origin, workarea_size);
|
||||
|
||||
// Translate the window so that its top/left is inside the work area.
|
||||
origin.x = std::max(origin.x, workarea_origin.x);
|
||||
origin.y = std::max(origin.y, workarea_origin.y);
|
||||
|
||||
// Crop the window if it extends past the bottom/right of the work area.
|
||||
Win32Window::Point workarea_bottom_right(
|
||||
workarea_origin.x + workarea_size.width,
|
||||
workarea_origin.y + workarea_size.height);
|
||||
|
||||
size.width = std::min(size.width, workarea_bottom_right.x - origin.x);
|
||||
size.height = std::min(size.height, workarea_bottom_right.y - origin.y);
|
||||
}
|
||||
}
|
||||
12
flutter/windows/runner/win32_desktop.h
Normal file
12
flutter/windows/runner/win32_desktop.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#ifndef RUNNER_WIN32_DESKTOP_H_
|
||||
#define RUNNER_WIN32_DESKTOP_H_
|
||||
|
||||
#include "win32_window.h"
|
||||
|
||||
namespace Win32Desktop
|
||||
{
|
||||
void GetWorkArea(Win32Window::Point& origin, Win32Window::Size& size);
|
||||
void FitToWorkArea(Win32Window::Point& origin, Win32Window::Size& size);
|
||||
}
|
||||
|
||||
#endif // RUNNER_WIN32_DESKTOP_H_
|
||||
@@ -170,6 +170,8 @@ extern "C"
|
||||
|
||||
typedef UINT (*pcNotifyClipboardMsg)(UINT32 connID, const NOTIFICATION_MESSAGE *msg);
|
||||
|
||||
typedef UINT (*pcHandleClipboardFiles)(UINT32 connID, size_t nFiles, WCHAR **fileNames);
|
||||
|
||||
typedef UINT (*pcCliprdrClientFormatList)(CliprdrClientContext *context,
|
||||
const CLIPRDR_FORMAT_LIST *formatList);
|
||||
typedef UINT (*pcCliprdrServerFormatList)(CliprdrClientContext *context,
|
||||
@@ -217,6 +219,7 @@ extern "C"
|
||||
pcCliprdrMonitorReady MonitorReady;
|
||||
pcCliprdrTempDirectory TempDirectory;
|
||||
pcNotifyClipboardMsg NotifyClipboardMsg;
|
||||
pcHandleClipboardFiles HandleClipboardFiles;
|
||||
pcCliprdrClientFormatList ClientFormatList;
|
||||
pcCliprdrServerFormatList ServerFormatList;
|
||||
pcCliprdrClientFormatListResponse ClientFormatListResponse;
|
||||
|
||||
@@ -132,6 +132,9 @@ pub enum ClipboardFile {
|
||||
requested_data: Vec<u8>,
|
||||
},
|
||||
TryEmpty,
|
||||
Files {
|
||||
files: Vec<(String, u64)>,
|
||||
},
|
||||
}
|
||||
|
||||
struct MsgChannel {
|
||||
|
||||
@@ -5,7 +5,7 @@ use hbb_common::{
|
||||
log,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use std::{path::PathBuf, sync::Arc, usize};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// local files are cached, this value should not be changed when copying files
|
||||
@@ -34,6 +34,7 @@ enum FileContentsRequest {
|
||||
struct ClipFiles {
|
||||
files: Vec<String>,
|
||||
file_list: Vec<LocalFile>,
|
||||
first_file_index: usize,
|
||||
files_pdu: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ impl ClipFiles {
|
||||
fn clear(&mut self) {
|
||||
self.files.clear();
|
||||
self.file_list.clear();
|
||||
self.first_file_index = usize::MAX;
|
||||
self.files_pdu.clear();
|
||||
}
|
||||
|
||||
@@ -50,6 +52,11 @@ impl ClipFiles {
|
||||
.map(|s| PathBuf::from(s))
|
||||
.collect::<Vec<_>>();
|
||||
self.file_list = construct_file_list(&clipboard_paths)?;
|
||||
self.first_file_index = self
|
||||
.file_list
|
||||
.iter()
|
||||
.position(|f| !f.path.is_dir())
|
||||
.unwrap_or(usize::MAX);
|
||||
self.files = clipboard_files.to_vec();
|
||||
Ok(())
|
||||
}
|
||||
@@ -63,6 +70,33 @@ impl ClipFiles {
|
||||
self.files_pdu = data.to_vec()
|
||||
}
|
||||
|
||||
fn get_files_for_audit(&self, request: &FileContentsRequest) -> Option<ClipboardFile> {
|
||||
if let FileContentsRequest::Range {
|
||||
file_idx, offset, ..
|
||||
} = request
|
||||
{
|
||||
if *file_idx == self.first_file_index && *offset == 0 {
|
||||
let files: Vec<(String, u64)> = self
|
||||
.file_list
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
if f.path.is_file() {
|
||||
Some((f.path.to_string_lossy().to_string(), f.size))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<_>();
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
} else {
|
||||
return Some(ClipboardFile::Files { files });
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn serve_file_contents(
|
||||
&mut self,
|
||||
conn_id: i32,
|
||||
@@ -192,7 +226,7 @@ pub fn read_file_contents(
|
||||
n_position_low: i32,
|
||||
n_position_high: i32,
|
||||
cb_requested: i32,
|
||||
) -> Result<ClipboardFile, CliprdrError> {
|
||||
) -> Vec<Result<ClipboardFile, CliprdrError>> {
|
||||
let fcr = if dw_flags == 0x1 {
|
||||
FileContentsRequest::Size {
|
||||
stream_id,
|
||||
@@ -209,12 +243,18 @@ pub fn read_file_contents(
|
||||
length,
|
||||
}
|
||||
} else {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
return vec![Err(CliprdrError::InvalidRequest {
|
||||
description: format!("got invalid FileContentsRequest, dw_flats: {dw_flags}"),
|
||||
});
|
||||
})];
|
||||
};
|
||||
|
||||
CLIP_FILES.lock().serve_file_contents(conn_id, fcr)
|
||||
let mut clip_files = CLIP_FILES.lock();
|
||||
let mut res = vec![];
|
||||
if let Some(files_res) = clip_files.get_files_for_audit(&fcr) {
|
||||
res.push(Ok(files_res));
|
||||
}
|
||||
res.push(clip_files.serve_file_contents(conn_id, fcr));
|
||||
res
|
||||
}
|
||||
|
||||
pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> {
|
||||
|
||||
@@ -381,6 +381,9 @@ pub type pcCliprdrTempDirectory = ::std::option::Option<
|
||||
pub type pcNotifyClipboardMsg = ::std::option::Option<
|
||||
unsafe extern "C" fn(connID: UINT32, msg: *const NOTIFICATION_MESSAGE) -> UINT,
|
||||
>;
|
||||
pub type pcHandleClipboardFiles = ::std::option::Option<
|
||||
unsafe extern "C" fn(connID: UINT32, nFiles: size_t, fileNames: *mut *mut WCHAR) -> UINT,
|
||||
>;
|
||||
pub type pcCliprdrClientFormatList = ::std::option::Option<
|
||||
unsafe extern "C" fn(
|
||||
context: *mut CliprdrClientContext,
|
||||
@@ -492,6 +495,7 @@ pub struct _cliprdr_client_context {
|
||||
pub MonitorReady: pcCliprdrMonitorReady,
|
||||
pub TempDirectory: pcCliprdrTempDirectory,
|
||||
pub NotifyClipboardMsg: pcNotifyClipboardMsg,
|
||||
pub HandleClipboardFiles: pcHandleClipboardFiles,
|
||||
pub ClientFormatList: pcCliprdrClientFormatList,
|
||||
pub ServerFormatList: pcCliprdrServerFormatList,
|
||||
pub ClientFormatListResponse: pcCliprdrClientFormatListResponse,
|
||||
@@ -529,6 +533,7 @@ impl CliprdrClientContext {
|
||||
enable_others: bool,
|
||||
response_wait_timeout_secs: u32,
|
||||
notify_callback: pcNotifyClipboardMsg,
|
||||
handle_clipboard_files: pcHandleClipboardFiles,
|
||||
client_format_list: pcCliprdrClientFormatList,
|
||||
client_format_list_response: pcCliprdrClientFormatListResponse,
|
||||
client_format_data_request: pcCliprdrClientFormatDataRequest,
|
||||
@@ -547,6 +552,7 @@ impl CliprdrClientContext {
|
||||
MonitorReady: None,
|
||||
TempDirectory: None,
|
||||
NotifyClipboardMsg: notify_callback,
|
||||
HandleClipboardFiles: handle_clipboard_files,
|
||||
ClientFormatList: client_format_list,
|
||||
ServerFormatList: None,
|
||||
ClientFormatListResponse: client_format_list_response,
|
||||
@@ -758,6 +764,9 @@ pub fn server_clip_file(
|
||||
ret
|
||||
);
|
||||
}
|
||||
ClipboardFile::Files { .. } => {
|
||||
// unreachable
|
||||
}
|
||||
}
|
||||
ret
|
||||
}
|
||||
@@ -967,6 +976,7 @@ pub fn create_cliprdr_context(
|
||||
enable_others,
|
||||
response_wait_timeout_secs,
|
||||
Some(notify_callback),
|
||||
Some(handle_clipboard_files),
|
||||
Some(client_format_list),
|
||||
Some(client_format_list_response),
|
||||
Some(client_format_data_request),
|
||||
@@ -1021,6 +1031,61 @@ extern "C" fn notify_callback(conn_id: UINT32, msg: *const NOTIFICATION_MESSAGE)
|
||||
0
|
||||
}
|
||||
|
||||
extern "C" fn handle_clipboard_files(
|
||||
conn_id: UINT32,
|
||||
n_files: size_t,
|
||||
file_names: *mut *mut WCHAR,
|
||||
) -> UINT {
|
||||
if n_files == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let data = unsafe {
|
||||
let mut files = Vec::new();
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
for i in 0..n_files {
|
||||
let file_name_ptr = *file_names.offset(i as isize);
|
||||
if !file_name_ptr.is_null() {
|
||||
let mut len = 0;
|
||||
while *file_name_ptr.offset(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(file_name_ptr, len as usize);
|
||||
let os_string = OsString::from_wide(slice);
|
||||
match os_string.to_str() {
|
||||
Some(n) => match std::fs::metadata(n) {
|
||||
Ok(meta) => {
|
||||
if meta.is_file() {
|
||||
files.push((n.to_owned(), meta.len()));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"handle_clipboard_files: Failed to get metadata for file '{}': {}",
|
||||
n,
|
||||
e
|
||||
);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::warn!("handle_clipboard_files: Failed to convert file name to UTF-8");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ClipboardFile::Files { files }
|
||||
};
|
||||
// no need to handle result here
|
||||
allow_err!(send_data(conn_id as _, data));
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
extern "C" fn client_format_list(
|
||||
_context: *mut CliprdrClientContext,
|
||||
clip_format_list: *const CLIPRDR_FORMAT_LIST,
|
||||
|
||||
@@ -239,6 +239,7 @@ struct wf_clipboard
|
||||
size_t nFiles;
|
||||
size_t file_array_size;
|
||||
WCHAR **file_names;
|
||||
size_t first_file_index;
|
||||
FILEDESCRIPTORW **fileDescriptor;
|
||||
|
||||
BOOL legacyApi;
|
||||
@@ -2024,6 +2025,7 @@ static void clear_file_array(wfClipboard *clipboard)
|
||||
|
||||
clipboard->file_array_size = 0;
|
||||
clipboard->nFiles = 0;
|
||||
clipboard->first_file_index = (size_t)-1;
|
||||
}
|
||||
|
||||
static BOOL wf_cliprdr_get_file_contents(WCHAR *file_name, BYTE *buffer, LONG positionLow,
|
||||
@@ -2179,6 +2181,11 @@ static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_fi
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if ((clipboard->fileDescriptor[clipboard->nFiles]->dwFileAttributes &
|
||||
FILE_ATTRIBUTE_DIRECTORY) == 0) {
|
||||
clipboard->first_file_index = clipboard->nFiles;
|
||||
}
|
||||
|
||||
clipboard->nFiles++;
|
||||
return TRUE;
|
||||
}
|
||||
@@ -2968,6 +2975,14 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
|
||||
{
|
||||
LARGE_INTEGER dlibMove;
|
||||
ULARGE_INTEGER dlibNewPosition;
|
||||
|
||||
if (clipboard->nFiles > 0 &&
|
||||
fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index &&
|
||||
fileContentsRequest->nPositionLow == 0 &&
|
||||
fileContentsRequest->nPositionHigh == 0) {
|
||||
clipboard->context->HandleClipboardFiles(fileContentsRequest->connID, clipboard->nFiles, clipboard->file_names);
|
||||
}
|
||||
|
||||
dlibMove.HighPart = fileContentsRequest->nPositionHigh;
|
||||
dlibMove.LowPart = fileContentsRequest->nPositionLow;
|
||||
hRet = IStream_Seek(pStreamStc, dlibMove, STREAM_SEEK_SET, &dlibNewPosition);
|
||||
@@ -2999,6 +3014,13 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
|
||||
rc = ERROR_INTERNAL_ERROR;
|
||||
goto exit;
|
||||
}
|
||||
|
||||
if (clipboard->nFiles > 0 &&
|
||||
fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index &&
|
||||
fileContentsRequest->nPositionLow == 0 &&
|
||||
fileContentsRequest->nPositionHigh == 0) {
|
||||
clipboard->context->HandleClipboardFiles(fileContentsRequest->connID, clipboard->nFiles, clipboard->file_names);
|
||||
}
|
||||
bRet = wf_cliprdr_get_file_contents(
|
||||
clipboard->file_names[fileContentsRequest->listIndex], pData,
|
||||
fileContentsRequest->nPositionLow, fileContentsRequest->nPositionHigh, cbRequested,
|
||||
|
||||
Submodule libs/hbb_common updated: f91459c4ab...5ed0afde08
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.1"
|
||||
version = "1.4.3"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
@@ -15,6 +15,14 @@ md5 = "0.7"
|
||||
winapi = { version = "0.3", features = ["winbase"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
"Wdk",
|
||||
"Wdk_System",
|
||||
"Wdk_System_SystemServices",
|
||||
"Win32",
|
||||
"Win32_System",
|
||||
"Win32_System_SystemInformation",
|
||||
] }
|
||||
native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]}
|
||||
|
||||
[package.metadata.winres]
|
||||
|
||||
@@ -92,12 +92,46 @@ fn setup(
|
||||
}
|
||||
write_meta(&dir, ts);
|
||||
#[cfg(windows)]
|
||||
windows::copy_runtime_broker(&dir);
|
||||
win::copy_runtime_broker(&dir);
|
||||
#[cfg(linux)]
|
||||
reader.configure_permission(&dir);
|
||||
Some(dir.join(&reader.exe))
|
||||
}
|
||||
|
||||
fn use_null_stdio() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// When running in CMD on Windows 7, using Stdio::inherit() with spawn returns an "invalid handle" error.
|
||||
// Since using Stdio::null() didn’t cause any issues, and determining whether the program is launched from CMD or by double-clicking would require calling more APIs during startup, we also use Stdio::null() when launched by double-clicking on Windows 7.
|
||||
let is_windows_7 = is_windows_7();
|
||||
println!("is windows7: {}", is_windows_7);
|
||||
return is_windows_7;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_windows_7() -> bool {
|
||||
use windows::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
unsafe {
|
||||
let mut version_info = OSVERSIONINFOW::default();
|
||||
version_info.dwOSVersionInfoSize = std::mem::size_of::<OSVERSIONINFOW>() as u32;
|
||||
|
||||
if RtlGetVersion(&mut version_info).is_ok() {
|
||||
// Windows 7 is version 6.1
|
||||
println!(
|
||||
"Windows version: {}.{}",
|
||||
version_info.dwMajorVersion, version_info.dwMinorVersion
|
||||
);
|
||||
return version_info.dwMajorVersion == 6 && version_info.dwMinorVersion == 1;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
|
||||
println!("executing {}", path.display());
|
||||
// setup env
|
||||
@@ -114,12 +148,18 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
|
||||
cmd.env(SET_FOREGROUND_WINDOW_ENV_KEY, "1");
|
||||
}
|
||||
}
|
||||
let _child = cmd
|
||||
.env(APPNAME_RUNTIME_ENV_KEY, exe_name)
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn();
|
||||
|
||||
cmd.env(APPNAME_RUNTIME_ENV_KEY, exe_name);
|
||||
if use_null_stdio() {
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
} else {
|
||||
cmd.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
}
|
||||
let _child = cmd.spawn();
|
||||
|
||||
#[cfg(windows)]
|
||||
if _ui {
|
||||
@@ -168,7 +208,7 @@ fn main() {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows {
|
||||
mod win {
|
||||
use std::{fs, os::windows::process::CommandExt, path::Path, process::Command};
|
||||
|
||||
// Used for privacy mode(magnifier impl).
|
||||
|
||||
@@ -62,21 +62,15 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf {
|
||||
}
|
||||
path.push(target);
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"cargo:rustc-link-lib=static={}",
|
||||
name.trim_start_matches("lib")
|
||||
)
|
||||
"cargo:rustc-link-lib=static={}",
|
||||
name.trim_start_matches("lib")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"cargo:rustc-link-search={}",
|
||||
path.join("lib").to_str().unwrap()
|
||||
)
|
||||
"cargo:rustc-link-search={}",
|
||||
path.join("lib").to_str().unwrap()
|
||||
);
|
||||
let include = path.join("include");
|
||||
println!("{}", format!("cargo:include={}", include.to_str().unwrap()));
|
||||
println!("cargo:include={}", include.to_str().unwrap());
|
||||
include
|
||||
}
|
||||
|
||||
@@ -111,23 +105,17 @@ fn link_homebrew_m1(name: &str) -> PathBuf {
|
||||
path.push(directories.pop().unwrap());
|
||||
// Link the library.
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"cargo:rustc-link-lib=static={}",
|
||||
name.trim_start_matches("lib")
|
||||
)
|
||||
"cargo:rustc-link-lib=static={}",
|
||||
name.trim_start_matches("lib")
|
||||
);
|
||||
// Add the library path.
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"cargo:rustc-link-search={}",
|
||||
path.join("lib").to_str().unwrap()
|
||||
)
|
||||
"cargo:rustc-link-search={}",
|
||||
path.join("lib").to_str().unwrap()
|
||||
);
|
||||
// Add the include path.
|
||||
let include = path.join("include");
|
||||
println!("{}", format!("cargo:include={}", include.to_str().unwrap()));
|
||||
println!("cargo:include={}", include.to_str().unwrap());
|
||||
include
|
||||
}
|
||||
|
||||
@@ -239,6 +227,24 @@ fn ffmpeg() {
|
||||
*/
|
||||
|
||||
fn main() {
|
||||
// there is problem with cfg(target_os) in build.rs, so use our workaround
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
|
||||
// We check if is macos, because macos uses rust 1.8.1.
|
||||
// `cargo::rustc-check-cfg` is new with Cargo 1.80.
|
||||
// No need to run `cargo version` to get the version here, because:
|
||||
// The following lines are used to suppress the lint warnings.
|
||||
// warning: unexpected `cfg` condition name: `quartz`
|
||||
if cfg!(target_os = "macos") {
|
||||
if target_os != "ios" {
|
||||
println!("cargo::rustc-check-cfg=cfg(android)");
|
||||
println!("cargo::rustc-check-cfg=cfg(dxgi)");
|
||||
println!("cargo::rustc-check-cfg=cfg(quartz)");
|
||||
println!("cargo::rustc-check-cfg=cfg(x11)");
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^ new with Cargo 1.80
|
||||
}
|
||||
}
|
||||
|
||||
// note: all link symbol names in x86 (32-bit) are prefixed wth "_".
|
||||
// run "rustup show" to show current default toolchain, if it is stable-x86-pc-windows-msvc,
|
||||
// please install x64 toolchain by "rustup toolchain install stable-x86_64-pc-windows-msvc",
|
||||
@@ -256,8 +262,6 @@ fn main() {
|
||||
gen_vcpkg_package("libyuv", "yuv_ffi.h", "yuv_ffi.rs", ".*");
|
||||
// ffmpeg();
|
||||
|
||||
// there is problem with cfg(target_os) in build.rs, so use our workaround
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
if target_os == "ios" {
|
||||
// nothing
|
||||
} else if target_os == "android" {
|
||||
|
||||
@@ -17,7 +17,9 @@ use hbb_common::message_proto::{DisplayInfo, Resolution};
|
||||
use crate::AdapterDevice;
|
||||
|
||||
use crate::common::{bail, ResultType};
|
||||
use crate::{Frame, PixelBuffer, Pixfmt, TraitCapturer};
|
||||
use crate::{Frame, TraitCapturer};
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use crate::{PixelBuffer, Pixfmt};
|
||||
|
||||
pub const PRIMARY_CAMERA_IDX: usize = 0;
|
||||
lazy_static::lazy_static! {
|
||||
@@ -162,11 +164,11 @@ impl Cameras {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
pub fn exists(index: usize) -> bool {
|
||||
pub fn exists(_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn get_camera_resolution(index: usize) -> ResultType<Resolution> {
|
||||
pub fn get_camera_resolution(_index: usize) -> ResultType<Resolution> {
|
||||
bail!(CAMERA_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
@@ -174,7 +176,7 @@ impl Cameras {
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn get_capturer(current: usize) -> ResultType<Box<dyn TraitCapturer>> {
|
||||
pub fn get_capturer(_current: usize) -> ResultType<Box<dyn TraitCapturer>> {
|
||||
bail!(CAMERA_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
@@ -201,6 +203,7 @@ impl CameraCapturer {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
fn new(_current: usize) -> ResultType<Self> {
|
||||
bail!(CAMERA_NOT_SUPPORTED);
|
||||
|
||||
@@ -18,10 +18,17 @@ use crate::{
|
||||
CodecFormat, EncodeInput, EncodeYuvFormat, ImageRgb, ImageTexture,
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
feature = "hwcodec",
|
||||
feature = "mediacodec",
|
||||
feature = "vram",
|
||||
target_os = "windows"
|
||||
))]
|
||||
use hbb_common::config::option2bool;
|
||||
use hbb_common::{
|
||||
anyhow::anyhow,
|
||||
bail,
|
||||
config::{option2bool, Config, PeerConfig},
|
||||
config::{Config, PeerConfig},
|
||||
lazy_static, log,
|
||||
message_proto::{
|
||||
supported_decoding::PreferCodec, video_frame, Chroma, CodecAbility, EncodedVideoFrames,
|
||||
|
||||
@@ -661,7 +661,9 @@ fn on_create_session_response(
|
||||
Variant(Box::new("u3".to_string())),
|
||||
);
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html
|
||||
// args.insert("multiple".into(), Variant(Box::new(true)));
|
||||
if is_server_running() {
|
||||
args.insert("multiple".into(), Variant(Box::new(true)));
|
||||
}
|
||||
args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32)));
|
||||
|
||||
let path = portal.select_sources(ses.clone(), args)?;
|
||||
@@ -725,7 +727,9 @@ fn on_select_devices_response(
|
||||
Variant(Box::new("u3".to_string())),
|
||||
);
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html
|
||||
// args.insert("multiple".into(), Variant(Box::new(true)));
|
||||
if is_server_running() {
|
||||
args.insert("multiple".into(), Variant(Box::new(true)));
|
||||
}
|
||||
args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32)));
|
||||
|
||||
let session = session.clone();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.1
|
||||
pkgver=1.4.3
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
771
res/ab.py
Normal file
771
res/ab.py
Normal file
@@ -0,0 +1,771 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def get_personal_ab(url, token):
|
||||
"""Get personal address book GUID"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.get(f"{url}/api/ab/personal", headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
return f"Error: {response.status_code} - {response.text}"
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def view_shared_abs(url, token, name=None):
|
||||
"""View all shared address books (excluding personal ones)"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
pageSize = 30
|
||||
params = {
|
||||
"name": name,
|
||||
}
|
||||
|
||||
filtered_params = {
|
||||
k: "%" + v + "%" if (v != "-" and "%" not in v and k != "name") else v
|
||||
for k, v in params.items()
|
||||
if v is not None
|
||||
}
|
||||
filtered_params["pageSize"] = pageSize
|
||||
|
||||
abs = []
|
||||
current = 1
|
||||
|
||||
while True:
|
||||
filtered_params["current"] = current
|
||||
response = requests.get(f"{url}/api/ab/shared/profiles", headers=headers, params=filtered_params)
|
||||
response_json = response.json()
|
||||
|
||||
data = response_json.get("data", [])
|
||||
abs.extend(data)
|
||||
|
||||
total = response_json.get("total", 0)
|
||||
current += pageSize
|
||||
if len(data) < pageSize or current > total:
|
||||
break
|
||||
|
||||
return abs
|
||||
|
||||
|
||||
def get_ab_by_name(url, token, ab_name):
|
||||
"""Get address book by name"""
|
||||
abs = view_shared_abs(url, token, ab_name)
|
||||
for ab in abs:
|
||||
if ab["name"] == ab_name:
|
||||
return ab
|
||||
return None
|
||||
|
||||
|
||||
def view_ab_peers(url, token, ab_guid, peer_id=None, alias=None):
|
||||
"""View peers in an address book"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
pageSize = 30
|
||||
params = {
|
||||
"ab": ab_guid,
|
||||
"id": peer_id,
|
||||
"alias": alias,
|
||||
}
|
||||
|
||||
filtered_params = {
|
||||
k: "%" + v + "%" if (v != "-" and "%" not in v and k not in ["ab"]) else v
|
||||
for k, v in params.items()
|
||||
if v is not None
|
||||
}
|
||||
filtered_params["pageSize"] = pageSize
|
||||
|
||||
peers = []
|
||||
current = 1
|
||||
|
||||
while True:
|
||||
filtered_params["current"] = current
|
||||
response = requests.get(f"{url}/api/ab/peers", headers=headers, params=filtered_params)
|
||||
response_json = response.json()
|
||||
|
||||
data = response_json.get("data", [])
|
||||
peers.extend(data)
|
||||
|
||||
total = response_json.get("total", 0)
|
||||
current += pageSize
|
||||
if len(data) < pageSize or current > total:
|
||||
break
|
||||
|
||||
return peers
|
||||
|
||||
|
||||
def view_ab_tags(url, token, ab_guid):
|
||||
"""View tags in an address book"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.get(f"{url}/api/ab/tags/{ab_guid}", headers=headers)
|
||||
response_json = check_response(response)
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(response_json, tuple) and response_json[0] == "Failed":
|
||||
print(f"Error: {response_json[1]} - {response_json[2]}")
|
||||
return []
|
||||
|
||||
# Format color values as hex
|
||||
if response_json:
|
||||
for tag in response_json:
|
||||
if "color" in tag and tag["color"] is not None:
|
||||
# Convert color to hex format
|
||||
color_value = tag["color"]
|
||||
if isinstance(color_value, int):
|
||||
tag["color"] = f"0x{color_value:08X}"
|
||||
|
||||
return response_json if response_json else []
|
||||
|
||||
|
||||
def check_response(response):
|
||||
"""Check API response and return result"""
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response_json = response.json()
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
else:
|
||||
return "Failed", response.status_code, response.text
|
||||
|
||||
|
||||
def add_peer(url, token, ab_guid, peer_id, alias=None, note=None, tags=None, password=None):
|
||||
"""Add a peer to address book"""
|
||||
print(f"Adding peer {peer_id} to address book")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
payload = {
|
||||
"id": peer_id,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
# Add peer info if provided
|
||||
info = {}
|
||||
if alias:
|
||||
info["alias"] = alias
|
||||
if tags:
|
||||
info["tags"] = tags if isinstance(tags, list) else [tags]
|
||||
if password:
|
||||
info["password"] = password
|
||||
|
||||
if info:
|
||||
payload.update(info)
|
||||
|
||||
response = requests.post(f"{url}/api/ab/peer/add/{ab_guid}", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def delete_peer(url, token, ab_guid, peer_ids):
|
||||
"""Delete peers from address book by IDs"""
|
||||
if isinstance(peer_ids, str):
|
||||
peer_ids = [peer_ids]
|
||||
|
||||
print(f"Deleting peers {peer_ids} from address book")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.delete(f"{url}/api/ab/peer/{ab_guid}", headers=headers, json=peer_ids)
|
||||
return check_response(response)
|
||||
|
||||
def update_peer(url, token, ab_guid, peer_id, alias=None, note=None, tags=None, password=None):
|
||||
"""Update a peer in address book"""
|
||||
print(f"Updating peer {peer_id} in address book")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Check if at least one parameter is provided for update
|
||||
update_params = [alias, note, tags, password]
|
||||
if all(param is None for param in update_params):
|
||||
return "Error: At least one parameter must be specified for update"
|
||||
|
||||
payload = {
|
||||
"id": peer_id,
|
||||
}
|
||||
|
||||
# Add fields to update
|
||||
info = {}
|
||||
if alias is not None:
|
||||
info["alias"] = alias
|
||||
if tags is not None:
|
||||
info["tags"] = tags if isinstance(tags, list) else [tags]
|
||||
if password is not None:
|
||||
info["password"] = password
|
||||
|
||||
if info:
|
||||
payload.update(info)
|
||||
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
|
||||
response = requests.put(f"{url}/api/ab/peer/update/{ab_guid}", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def str2color(tag_name, existing_colors=None):
|
||||
"""Generate color for tag name similar to str2color2 function"""
|
||||
if existing_colors is None:
|
||||
existing_colors = []
|
||||
|
||||
color_map = {
|
||||
"red": 0xFFFF0000,
|
||||
"green": 0xFF008000,
|
||||
"blue": 0xFF0000FF,
|
||||
"orange": 0xFFFF9800,
|
||||
"purple": 0xFF9C27B0,
|
||||
"grey": 0xFF9E9E9E,
|
||||
"cyan": 0xFF00BCD4,
|
||||
"lime": 0xFFCDDC39,
|
||||
"teal": 0xFF009688,
|
||||
"pink": 0xFFF48FB1,
|
||||
"indigo": 0xFF3F51B5,
|
||||
"brown": 0xFF795548,
|
||||
}
|
||||
|
||||
lower_name = tag_name.lower()
|
||||
|
||||
# Check if tag name matches a predefined color
|
||||
if lower_name in color_map:
|
||||
return color_map[lower_name]
|
||||
|
||||
# Special case for yellow
|
||||
if lower_name == "yellow":
|
||||
return 0xFFFFFF00
|
||||
|
||||
# Generate hash-based color
|
||||
hash_value = 0
|
||||
for char in tag_name:
|
||||
hash_value += ord(char)
|
||||
|
||||
color_list = list(color_map.values())
|
||||
hash_value = hash_value % len(color_list)
|
||||
result = color_list[hash_value]
|
||||
|
||||
# If color is already used, try to find an unused one
|
||||
if result in existing_colors:
|
||||
for color in color_list:
|
||||
if color not in existing_colors:
|
||||
result = color
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def add_tag(url, token, ab_guid, tag_name, color=None):
|
||||
"""Add a tag to address book"""
|
||||
print(f"Adding tag '{tag_name}' to address book")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# If no color specified, generate one based on tag name
|
||||
if color is None:
|
||||
# Get existing tags to avoid color conflicts
|
||||
try:
|
||||
existing_tags = view_ab_tags(url, token, ab_guid)
|
||||
existing_colors = [tag.get("color", 0) for tag in existing_tags]
|
||||
color = str2color(tag_name, existing_colors)
|
||||
except:
|
||||
# Fallback to default color if we can't get existing tags
|
||||
color = str2color(tag_name)
|
||||
|
||||
payload = {
|
||||
"name": tag_name,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
response = requests.post(f"{url}/api/ab/tag/add/{ab_guid}", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def update_tag(url, token, ab_guid, tag_name, color):
|
||||
"""Update a tag in address book"""
|
||||
print(f"Updating tag '{tag_name}' in address book")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
payload = {
|
||||
"name": tag_name,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
response = requests.put(f"{url}/api/ab/tag/update/{ab_guid}", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def delete_tags(url, token, ab_guid, tag_names):
|
||||
"""Delete tags from address book"""
|
||||
if isinstance(tag_names, str):
|
||||
tag_names = [tag_names]
|
||||
|
||||
print(f"Deleting tags {tag_names} from address book")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.delete(f"{url}/api/ab/tag/{ab_guid}", headers=headers, json=tag_names)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def add_shared_ab(url, token, name, note=None, password=None):
|
||||
"""Add a new shared address book"""
|
||||
print(f"Adding shared address book '{name}'")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
payload = {
|
||||
"name": name,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
# Add info if password is provided
|
||||
if password:
|
||||
payload["info"] = {
|
||||
"password": password
|
||||
}
|
||||
|
||||
response = requests.post(f"{url}/api/ab/shared/add", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def update_shared_ab(url, token, ab_guid, name=None, note=None, owner=None, password=None):
|
||||
"""Update a shared address book"""
|
||||
print(f"Updating shared address book {ab_guid}")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Check if at least one parameter is provided for update
|
||||
update_params = [name, note, owner, password]
|
||||
if all(param is None for param in update_params):
|
||||
return "Error: At least one parameter must be specified for update"
|
||||
|
||||
payload = {
|
||||
"guid": ab_guid,
|
||||
}
|
||||
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if owner is not None:
|
||||
payload["owner"] = owner
|
||||
if password is not None:
|
||||
payload["info"] = {
|
||||
"password": password
|
||||
}
|
||||
|
||||
response = requests.put(f"{url}/api/ab/shared/update/profile", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def delete_shared_abs(url, token, ab_guids):
|
||||
"""Delete shared address books"""
|
||||
if isinstance(ab_guids, str):
|
||||
ab_guids = [ab_guids]
|
||||
|
||||
print(f"Deleting shared address books {ab_guids}")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.delete(f"{url}/api/ab/shared", headers=headers, json=ab_guids)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def permission_to_string(permission):
|
||||
"""Convert numeric permission to string representation"""
|
||||
permission_map = {
|
||||
1: "ro", # Read
|
||||
2: "rw", # ReadWrite
|
||||
3: "full" # FullControl
|
||||
}
|
||||
return permission_map.get(permission, str(permission))
|
||||
|
||||
|
||||
def string_to_permission(permission_str):
|
||||
"""Convert string permission to numeric representation"""
|
||||
permission_map = {
|
||||
"ro": 1, # Read
|
||||
"rw": 2, # ReadWrite
|
||||
"full": 3 # FullControl
|
||||
}
|
||||
return permission_map.get(permission_str.lower(), None)
|
||||
|
||||
|
||||
def view_ab_rules(url, token, ab_guid):
|
||||
"""View rules in an address book"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
pageSize = 30
|
||||
params = {
|
||||
"ab": ab_guid,
|
||||
"pageSize": pageSize,
|
||||
}
|
||||
|
||||
rules = []
|
||||
current = 1
|
||||
|
||||
while True:
|
||||
params["current"] = current
|
||||
response = requests.get(f"{url}/api/ab/rules", headers=headers, params=params)
|
||||
response_json = response.json()
|
||||
|
||||
data = response_json.get("data", [])
|
||||
rules.extend(data)
|
||||
|
||||
total = response_json.get("total", 0)
|
||||
current += pageSize
|
||||
if len(data) < pageSize or current > total:
|
||||
break
|
||||
|
||||
# Convert numeric permissions to string format
|
||||
for rule in rules:
|
||||
if "rule" in rule:
|
||||
rule["rule"] = permission_to_string(rule["rule"])
|
||||
|
||||
return rules
|
||||
|
||||
|
||||
def add_ab_rule(url, token, ab_guid, rule_type, user=None, group=None, rule=1):
|
||||
"""Add a rule to address book"""
|
||||
print(f"Adding {rule_type} rule to address book")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
payload = {
|
||||
"guid": ab_guid,
|
||||
"rule": rule,
|
||||
}
|
||||
|
||||
if rule_type == "user" and user:
|
||||
payload["user"] = user
|
||||
elif rule_type == "group" and group:
|
||||
payload["group"] = group
|
||||
elif rule_type == "everyone":
|
||||
# For everyone, both user and group are None (not included in payload)
|
||||
pass
|
||||
|
||||
response = requests.post(f"{url}/api/ab/rule", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def update_ab_rule(url, token, rule_guid, rule):
|
||||
"""Update an address book rule"""
|
||||
print(f"Updating rule {rule_guid}")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
payload = {
|
||||
"guid": rule_guid,
|
||||
"rule": rule,
|
||||
}
|
||||
|
||||
response = requests.patch(f"{url}/api/ab/rule", headers=headers, json=payload)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def delete_ab_rules(url, token, rule_guids):
|
||||
"""Delete address book rules"""
|
||||
if isinstance(rule_guids, str):
|
||||
rule_guids = [rule_guids]
|
||||
|
||||
print(f"Deleting rules {rule_guids}")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.delete(f"{url}/api/ab/rules", headers=headers, json=rule_guids)
|
||||
return check_response(response)
|
||||
|
||||
|
||||
def main():
|
||||
def parse_color(value):
|
||||
"""Parse color value - supports both hex (0xFF00FF00) and decimal"""
|
||||
if value.startswith('0x') or value.startswith('0X'):
|
||||
return int(value, 16)
|
||||
else:
|
||||
return int(value)
|
||||
|
||||
def parse_permission(value):
|
||||
"""Parse permission value - supports both string (ro/rw/full) and numeric (1/2/3)"""
|
||||
# Try to parse as string first
|
||||
permission_num = string_to_permission(value)
|
||||
if permission_num is not None:
|
||||
return permission_num
|
||||
|
||||
# Try to parse as integer for backward compatibility
|
||||
try:
|
||||
num_value = int(value)
|
||||
if num_value in [1, 2, 3]:
|
||||
return num_value
|
||||
else:
|
||||
raise argparse.ArgumentTypeError(f"Invalid permission value: {value}. Must be one of: ro, rw, full, 1, 2, 3")
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(f"Invalid permission value: {value}. Must be one of: ro, rw, full, 1, 2, 3")
|
||||
|
||||
parser = argparse.ArgumentParser(description="Address Book manager")
|
||||
|
||||
# Required arguments
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["view-ab", "add-ab", "update-ab", "delete-ab", "get-personal-ab",
|
||||
"view-peer", "add-peer", "update-peer", "delete-peer",
|
||||
"view-tag", "add-tag", "update-tag", "delete-tag",
|
||||
"view-rule", "add-rule", "update-rule", "delete-rule"],
|
||||
help="Command to execute",
|
||||
)
|
||||
|
||||
# Global arguments (used by all commands)
|
||||
parser.add_argument("--url", required=True, help="URL of the API")
|
||||
parser.add_argument("--token", required=True, help="Bearer token for authentication")
|
||||
|
||||
# Address book identification (used by most commands except get-personal-ab)
|
||||
parser.add_argument("--ab-name", help="Address book name (for identification)")
|
||||
parser.add_argument("--ab-guid", help="Address book GUID (alternative to ab-name)")
|
||||
|
||||
# Address book management arguments
|
||||
parser.add_argument("--ab-update-name", help="New address book name (for update)")
|
||||
parser.add_argument("--note", help="Note field")
|
||||
parser.add_argument("--password", help="Password field")
|
||||
parser.add_argument("--owner", help="Address book owner (username)")
|
||||
|
||||
# Peer management arguments
|
||||
parser.add_argument("--peer-id", help="Peer ID")
|
||||
parser.add_argument("--alias", help="Peer alias")
|
||||
parser.add_argument("--tags", help="Peer tags (supports both 'tag1,tag2' and '[tag1,tag2]' formats, use '[]' to clear tags)")
|
||||
|
||||
# Tag management arguments
|
||||
parser.add_argument("--tag-name", help="Tag name")
|
||||
parser.add_argument("--tag-color", type=parse_color, help="Tag color (hex number like 0xFF00FF00 or decimal, auto-generated if not specified)")
|
||||
|
||||
# Rule management arguments
|
||||
parser.add_argument("--rule-type", choices=["user", "group", "everyone"], help="Rule type (auto-detected if not specified)")
|
||||
parser.add_argument("--rule-user", help="Rule target user name (auto-sets rule-type=user)")
|
||||
parser.add_argument("--rule-group", help="Rule target group name (auto-sets rule-type=group)")
|
||||
parser.add_argument("--rule-permission", type=parse_permission, help="Rule permission (ro=Read, rw=ReadWrite, full=FullControl, or numeric 1/2/3)")
|
||||
parser.add_argument("--rule-guid", help="Rule GUID (for update/delete)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Remove trailing slashes from URL
|
||||
while args.url.endswith("/"):
|
||||
args.url = args.url[:-1]
|
||||
|
||||
if args.command == "view-ab":
|
||||
# View all shared address books
|
||||
abs = view_shared_abs(args.url, args.token, args.ab_name)
|
||||
print(json.dumps(abs, indent=2))
|
||||
|
||||
elif args.command == "get-personal-ab":
|
||||
# Get personal address book GUID
|
||||
personal_ab = get_personal_ab(args.url, args.token)
|
||||
print(json.dumps(personal_ab, indent=2))
|
||||
|
||||
elif args.command in ["add-ab", "update-ab", "delete-ab"]:
|
||||
# Address book management commands
|
||||
if args.command == "add-ab":
|
||||
if not args.ab_name:
|
||||
print("Error: --ab-name is required for add-ab command")
|
||||
return
|
||||
|
||||
result = add_shared_ab(args.url, args.token, args.ab_name, args.note, args.password)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command in ["update-ab", "delete-ab"]:
|
||||
# Commands that need ab-name or ab-guid
|
||||
if not args.ab_name and not args.ab_guid:
|
||||
print("Error: --ab-name or --ab-guid is required for this command")
|
||||
return
|
||||
|
||||
if args.ab_name and args.ab_guid:
|
||||
print("Error: Cannot specify both --ab-name and --ab-guid")
|
||||
return
|
||||
|
||||
if args.ab_guid:
|
||||
ab_guid = args.ab_guid
|
||||
print(f"Working with address book GUID: {ab_guid}")
|
||||
else:
|
||||
# Get address book by name
|
||||
ab = get_ab_by_name(args.url, args.token, args.ab_name)
|
||||
if not ab:
|
||||
print(f"Error: Address book '{args.ab_name}' not found")
|
||||
return
|
||||
ab_guid = ab["guid"]
|
||||
print(f"Working with address book: {args.ab_name} (GUID: {ab_guid})")
|
||||
|
||||
if args.command == "update-ab":
|
||||
result = update_shared_ab(args.url, args.token, ab_guid, args.ab_update_name, args.note, args.owner, args.password)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "delete-ab":
|
||||
result = delete_shared_abs(args.url, args.token, ab_guid)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command in ["view-peer", "add-peer", "update-peer", "delete-peer", "view-tag", "add-tag", "update-tag", "delete-tag", "view-rule", "add-rule", "update-rule", "delete-rule"]:
|
||||
if not args.ab_name and not args.ab_guid:
|
||||
print("Error: --ab-name or --ab-guid is required for this command")
|
||||
return
|
||||
|
||||
if args.ab_name and args.ab_guid:
|
||||
print("Error: Cannot specify both --ab-name and --ab-guid")
|
||||
return
|
||||
|
||||
if args.ab_guid:
|
||||
ab_guid = args.ab_guid
|
||||
print(f"Working with address book GUID: {ab_guid}")
|
||||
else:
|
||||
# Get address book by name
|
||||
ab = get_ab_by_name(args.url, args.token, args.ab_name)
|
||||
if not ab:
|
||||
print(f"Error: Address book '{args.ab_name}' not found")
|
||||
return
|
||||
|
||||
ab_guid = ab["guid"]
|
||||
print(f"Working with address book: {args.ab_name} (GUID: {ab_guid})")
|
||||
|
||||
if args.command == "view-peer":
|
||||
peers = view_ab_peers(args.url, args.token, ab_guid, args.peer_id, args.alias)
|
||||
print(json.dumps(peers, indent=2))
|
||||
|
||||
elif args.command == "add-peer":
|
||||
if not args.peer_id:
|
||||
print("Error: --peer-id is required for add-peer command")
|
||||
return
|
||||
|
||||
# Handle tags parsing - support both [tag1,tag2] and tag1,tag2 formats
|
||||
tags = None
|
||||
if args.tags is not None:
|
||||
if args.tags == "[]":
|
||||
tags = [] # Empty list to clear tags
|
||||
else:
|
||||
# Remove brackets if present and split by comma
|
||||
tags_str = args.tags.strip()
|
||||
if tags_str.startswith('[') and tags_str.endswith(']'):
|
||||
tags_str = tags_str[1:-1] # Remove brackets
|
||||
tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()]
|
||||
|
||||
result = add_peer(
|
||||
args.url,
|
||||
args.token,
|
||||
ab_guid,
|
||||
args.peer_id,
|
||||
args.alias,
|
||||
args.note,
|
||||
tags,
|
||||
args.password
|
||||
)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "update-peer":
|
||||
if not args.peer_id:
|
||||
print("Error: --peer-id is required for update-peer command")
|
||||
return
|
||||
|
||||
# Handle tags parsing - support both [tag1,tag2] and tag1,tag2 formats
|
||||
tags = None
|
||||
if args.tags is not None:
|
||||
if args.tags == "[]":
|
||||
tags = [] # Empty list to clear tags
|
||||
else:
|
||||
# Remove brackets if present and split by comma
|
||||
tags_str = args.tags.strip()
|
||||
if tags_str.startswith('[') and tags_str.endswith(']'):
|
||||
tags_str = tags_str[1:-1] # Remove brackets
|
||||
tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()]
|
||||
|
||||
result = update_peer(
|
||||
args.url,
|
||||
args.token,
|
||||
ab_guid,
|
||||
args.peer_id,
|
||||
args.alias,
|
||||
args.note,
|
||||
tags,
|
||||
args.password
|
||||
)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "delete-peer":
|
||||
if not args.peer_id:
|
||||
print("Error: --peer-id is required for delete-peer command")
|
||||
return
|
||||
|
||||
result = delete_peer(args.url, args.token, ab_guid, args.peer_id)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "view-tag":
|
||||
tags = view_ab_tags(args.url, args.token, ab_guid)
|
||||
print(json.dumps(tags, indent=2))
|
||||
|
||||
elif args.command == "add-tag":
|
||||
if not args.tag_name:
|
||||
print("Error: --tag-name is required for add-tag command")
|
||||
return
|
||||
|
||||
result = add_tag(args.url, args.token, ab_guid, args.tag_name, args.tag_color)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "update-tag":
|
||||
if not args.tag_name:
|
||||
print("Error: --tag-name is required for update-tag command")
|
||||
return
|
||||
|
||||
result = update_tag(args.url, args.token, ab_guid, args.tag_name, args.tag_color)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "delete-tag":
|
||||
if not args.tag_name:
|
||||
print("Error: --tag-name is required for delete-tag command")
|
||||
return
|
||||
|
||||
result = delete_tags(args.url, args.token, ab_guid, args.tag_name)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "view-rule":
|
||||
rules = view_ab_rules(args.url, args.token, ab_guid)
|
||||
print(json.dumps(rules, indent=2))
|
||||
|
||||
elif args.command == "add-rule":
|
||||
if not args.rule_permission:
|
||||
print("Error: --rule-permission is required for add-rule command")
|
||||
return
|
||||
|
||||
# Auto-detect rule type if not explicitly specified
|
||||
if not args.rule_type:
|
||||
if args.rule_user and args.rule_group:
|
||||
print("Error: Cannot specify both --rule-user and --rule-group")
|
||||
return
|
||||
elif args.rule_user:
|
||||
rule_type = "user"
|
||||
elif args.rule_group:
|
||||
rule_type = "group"
|
||||
else:
|
||||
print("Error: Must specify --rule-type=everyone, --rule-user, or --rule-group")
|
||||
return
|
||||
else:
|
||||
rule_type = args.rule_type
|
||||
|
||||
# Validate explicit rule type with parameters
|
||||
if rule_type == "user" and not args.rule_user:
|
||||
print("Error: --rule-user is required when rule-type=user")
|
||||
return
|
||||
elif rule_type == "group" and not args.rule_group:
|
||||
print("Error: --rule-group is required when rule-type=group")
|
||||
return
|
||||
elif rule_type == "user" and args.rule_group:
|
||||
print("Error: Cannot specify --rule-group when rule-type=user")
|
||||
return
|
||||
elif rule_type == "group" and args.rule_user:
|
||||
print("Error: Cannot specify --rule-user when rule-type=group")
|
||||
return
|
||||
elif rule_type == "everyone" and (args.rule_user or args.rule_group):
|
||||
print("Error: Cannot specify --rule-user or --rule-group when rule-type=everyone")
|
||||
return
|
||||
|
||||
result = add_ab_rule(args.url, args.token, ab_guid, rule_type, args.rule_user, args.rule_group, args.rule_permission)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "update-rule":
|
||||
if not args.rule_guid:
|
||||
print("Error: --rule-guid is required for update-rule command")
|
||||
return
|
||||
if not args.rule_permission:
|
||||
print("Error: --rule-permission is required for update-rule command")
|
||||
return
|
||||
|
||||
result = update_ab_rule(args.url, args.token, args.rule_guid, args.rule_permission)
|
||||
print(f"Result: {result}")
|
||||
|
||||
elif args.command == "delete-rule":
|
||||
if not args.rule_guid:
|
||||
print("Error: --rule-guid is required for delete-rule command")
|
||||
return
|
||||
|
||||
result = delete_ab_rules(args.url, args.token, args.rule_guid)
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
370
res/audits.py
Normal file
370
res/audits.py
Normal file
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def format_timestamp(timestamp):
|
||||
"""Convert Unix timestamp to readable local datetime"""
|
||||
if timestamp is None:
|
||||
return None
|
||||
try:
|
||||
# Convert to local time
|
||||
local_dt = datetime.fromtimestamp(timestamp)
|
||||
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return timestamp
|
||||
|
||||
|
||||
def parse_local_time_to_utc_string(time_str):
|
||||
"""Parse local time string to UTC time string for API filtering"""
|
||||
try:
|
||||
# Parse the local time string
|
||||
local_dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S.%f")
|
||||
# Make the datetime object timezone-aware using system's local timezone
|
||||
local_dt = local_dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
||||
utc_dt = local_dt.astimezone(timezone.utc)
|
||||
return utc_dt.strftime("%Y-%m-%d %H:%M:%S.000")
|
||||
except ValueError:
|
||||
try:
|
||||
# Try without microseconds
|
||||
local_dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
|
||||
# Make the datetime object timezone-aware using system's local timezone
|
||||
local_dt = local_dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
||||
utc_dt = local_dt.astimezone(timezone.utc)
|
||||
return utc_dt.strftime("%Y-%m-%d %H:%M:%S.000")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_connection_type_name(conn_type):
|
||||
"""Convert connection type number to readable name"""
|
||||
type_map = {
|
||||
0: "Remote Desktop",
|
||||
1: "File Transfer",
|
||||
2: "Port Transfer",
|
||||
3: "View Camera",
|
||||
4: "Terminal"
|
||||
}
|
||||
return type_map.get(conn_type, f"Unknown ({conn_type})")
|
||||
|
||||
|
||||
def get_console_type_name(console_type):
|
||||
"""Convert console audit type number to readable name"""
|
||||
type_map = {
|
||||
0: "Group Management",
|
||||
1: "User Management",
|
||||
2: "Device Management",
|
||||
3: "Address Book Management"
|
||||
}
|
||||
return type_map.get(console_type, f"Unknown ({console_type})")
|
||||
|
||||
|
||||
def get_console_operation_name(operation_code):
|
||||
"""Convert console operation code to readable name"""
|
||||
operation_map = {
|
||||
0: "User Login",
|
||||
1: "Add Group",
|
||||
2: "Add User",
|
||||
3: "Add Device",
|
||||
4: "Delete Groups",
|
||||
5: "Disconnect Device",
|
||||
6: "Enable Users",
|
||||
7: "Disable Users",
|
||||
8: "Enable Devices",
|
||||
9: "Disable Devices",
|
||||
10: "Update Group",
|
||||
11: "Update User",
|
||||
12: "Update Device",
|
||||
13: "Delete User",
|
||||
14: "Delete Device",
|
||||
15: "Add Address Book",
|
||||
16: "Delete Address Book",
|
||||
17: "Change Address Book Name",
|
||||
18: "Delete Devices in the Address Book Recycle Bin",
|
||||
19: "Empty Address Book Recycle Bin",
|
||||
20: "Add Address Book Permission",
|
||||
21: "Delete Address Book Permission",
|
||||
22: "Update Address Book Permission"
|
||||
}
|
||||
return operation_map.get(operation_code, f"Unknown ({operation_code})")
|
||||
|
||||
|
||||
def get_alarm_type_name(alarm_type):
|
||||
"""Convert alarm type number to readable name"""
|
||||
type_map = {
|
||||
0: "Access attempt outside the IP whiltelist",
|
||||
1: "Over 30 consecutive access attempts",
|
||||
2: "Multiple access attempts within one minute",
|
||||
3: "Over 30 consecutive login attempts",
|
||||
4: "Multiple login attempts within one minute",
|
||||
5: "Multiple login attempts within one hour"
|
||||
}
|
||||
return type_map.get(alarm_type, f"Unknown ({alarm_type})")
|
||||
|
||||
|
||||
def enhance_audit_data(data, audit_type):
|
||||
"""Enhance audit data with readable formats"""
|
||||
if not data:
|
||||
return data
|
||||
|
||||
enhanced_data = []
|
||||
for item in data:
|
||||
enhanced_item = item.copy()
|
||||
|
||||
# Convert timestamps - replace original values
|
||||
if 'created_at' in enhanced_item:
|
||||
enhanced_item['created_at'] = format_timestamp(enhanced_item['created_at'])
|
||||
if 'end_time' in enhanced_item:
|
||||
enhanced_item['end_time'] = format_timestamp(enhanced_item['end_time'])
|
||||
|
||||
# Add type-specific enhancements - replace original values
|
||||
if audit_type == 'conn':
|
||||
if 'conn_type' in enhanced_item:
|
||||
enhanced_item['conn_type'] = get_connection_type_name(enhanced_item['conn_type'])
|
||||
else:
|
||||
enhanced_item['conn_type'] = "Not Logged In"
|
||||
|
||||
elif audit_type == 'console':
|
||||
if 'typ' in enhanced_item:
|
||||
# Replace typ field with type and convert to readable name
|
||||
enhanced_item['type'] = get_console_type_name(enhanced_item['typ'])
|
||||
del enhanced_item['typ']
|
||||
if 'iop' in enhanced_item:
|
||||
# Replace iop field with operation and convert to readable name
|
||||
enhanced_item['operation'] = get_console_operation_name(enhanced_item['iop'])
|
||||
del enhanced_item['iop']
|
||||
|
||||
elif audit_type == 'alarm' and 'typ' in enhanced_item:
|
||||
# Replace typ field with type and convert to readable name
|
||||
enhanced_item['type'] = get_alarm_type_name(enhanced_item['typ'])
|
||||
del enhanced_item['typ']
|
||||
|
||||
enhanced_data.append(enhanced_item)
|
||||
|
||||
return enhanced_data
|
||||
|
||||
|
||||
def check_response(response):
|
||||
"""Check API response and return result"""
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response_json = response.json()
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
else:
|
||||
return "Failed", response.status_code, response.text
|
||||
|
||||
|
||||
def view_audits_common(url, token, endpoint, filters=None, page_size=None, current=None,
|
||||
created_at=None, days_ago=None, non_wildcard_fields=None):
|
||||
"""Common function for viewing audits"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Set default page size and current page
|
||||
if page_size is None:
|
||||
page_size = 10
|
||||
if current is None:
|
||||
current = 1
|
||||
|
||||
params = {
|
||||
"pageSize": page_size,
|
||||
"current": current
|
||||
}
|
||||
|
||||
# Add filter parameters if provided
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if value is not None:
|
||||
params[key] = value
|
||||
|
||||
# Handle time filters
|
||||
if days_ago is not None:
|
||||
# Calculate datetime from days ago
|
||||
target_time = datetime.now() - timedelta(days=days_ago)
|
||||
# Convert to UTC time string using system timezone
|
||||
utc_timestamp = target_time.timestamp()
|
||||
utc_dt = datetime.fromtimestamp(utc_timestamp, timezone.utc)
|
||||
params["created_at"] = utc_dt.strftime("%Y-%m-%d %H:%M:%S.000")
|
||||
elif created_at:
|
||||
# Parse local time string and convert to UTC time string
|
||||
utc_time_str = parse_local_time_to_utc_string(created_at)
|
||||
if utc_time_str is not None:
|
||||
params["created_at"] = utc_time_str
|
||||
else:
|
||||
# If parsing fails, pass the original value
|
||||
params["created_at"] = created_at
|
||||
|
||||
# Apply wildcard patterns for string fields (excluding specific fields)
|
||||
if non_wildcard_fields is None:
|
||||
non_wildcard_fields = set()
|
||||
|
||||
# Always exclude these fields from wildcard treatment
|
||||
non_wildcard_fields.update(["created_at", "pageSize", "current"])
|
||||
|
||||
string_params = {}
|
||||
for k, v in params.items():
|
||||
if isinstance(v, str) and k not in non_wildcard_fields:
|
||||
if v != "-" and "%" not in v:
|
||||
string_params[k] = "%" + v + "%"
|
||||
else:
|
||||
string_params[k] = v
|
||||
else:
|
||||
string_params[k] = v
|
||||
|
||||
response = requests.get(f"{url}/api/audits/{endpoint}", headers=headers, params=string_params)
|
||||
response_json = response.json()
|
||||
|
||||
# Enhance the data with readable formats
|
||||
data = enhance_audit_data(response_json.get("data", []), endpoint)
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"total": response_json.get("total", 0),
|
||||
"current": current,
|
||||
"pageSize": page_size
|
||||
}
|
||||
|
||||
|
||||
def view_conn_audits(url, token, remote=None, conn_type=None,
|
||||
page_size=None, current=None, created_at=None, days_ago=None):
|
||||
"""View connection audits"""
|
||||
filters = {
|
||||
"remote": remote,
|
||||
"conn_type": conn_type
|
||||
}
|
||||
non_wildcard_fields = {"conn_type"}
|
||||
|
||||
return view_audits_common(
|
||||
url, token, "conn", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
||||
)
|
||||
|
||||
|
||||
def view_file_audits(url, token, remote=None,
|
||||
page_size=None, current=None, created_at=None, days_ago=None):
|
||||
"""View file audits"""
|
||||
filters = {
|
||||
"remote": remote
|
||||
}
|
||||
non_wildcard_fields = set()
|
||||
|
||||
return view_audits_common(
|
||||
url, token, "file", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
||||
)
|
||||
|
||||
|
||||
def view_alarm_audits(url, token, device=None,
|
||||
page_size=None, current=None, created_at=None, days_ago=None):
|
||||
"""View alarm audits"""
|
||||
filters = {
|
||||
"device": device
|
||||
}
|
||||
non_wildcard_fields = set()
|
||||
|
||||
return view_audits_common(
|
||||
url, token, "alarm", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
||||
)
|
||||
|
||||
|
||||
def view_console_audits(url, token, operator=None,
|
||||
page_size=None, current=None, created_at=None, days_ago=None):
|
||||
"""View console audits"""
|
||||
filters = {
|
||||
"operator": operator
|
||||
}
|
||||
non_wildcard_fields = set()
|
||||
|
||||
return view_audits_common(
|
||||
url, token, "console", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Audits manager")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["view-conn", "view-file", "view-alarm", "view-console"],
|
||||
help="Command to execute",
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="URL of the API")
|
||||
parser.add_argument("--token", required=True, help="Bearer token for authentication")
|
||||
|
||||
# Pagination parameters
|
||||
parser.add_argument("--page-size", type=int, default=10, help="Number of records per page (default: 10)")
|
||||
parser.add_argument("--current", type=int, default=1, help="Current page number (default: 1)")
|
||||
|
||||
# Time filtering parameters
|
||||
parser.add_argument("--created-at", help="Filter by creation time in local time (format: 2025-09-16 14:15:57 or 2025-09-16 14:15:57.000)")
|
||||
parser.add_argument("--days-ago", type=int, help="Filter by days ago (e.g., 7 for last 7 days)")
|
||||
|
||||
# Audit filters (simplified)
|
||||
parser.add_argument("--remote", help="Remote peer ID filter (for conn/file audits)")
|
||||
parser.add_argument("--device", help="Device ID filter (for alarm audits)")
|
||||
parser.add_argument("--conn-type", type=int, help="Connection type filter (for conn audits only): 0=Remote Desktop, 1=File Transfer, 2=Port Transfer, 3=View Camera, 4=Terminal")
|
||||
parser.add_argument("--operator", help="Operator filter (for console audits only)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Remove trailing slashes from URL
|
||||
while args.url.endswith("/"):
|
||||
args.url = args.url[:-1]
|
||||
|
||||
if args.command == "view-conn":
|
||||
# View connection audits
|
||||
result = view_conn_audits(
|
||||
args.url,
|
||||
args.token,
|
||||
args.remote,
|
||||
args.conn_type,
|
||||
args.page_size,
|
||||
args.current,
|
||||
args.created_at,
|
||||
args.days_ago
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
elif args.command == "view-file":
|
||||
# View file audits
|
||||
result = view_file_audits(
|
||||
args.url,
|
||||
args.token,
|
||||
args.remote,
|
||||
args.page_size,
|
||||
args.current,
|
||||
args.created_at,
|
||||
args.days_ago
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
elif args.command == "view-alarm":
|
||||
# View alarm audits
|
||||
result = view_alarm_audits(
|
||||
args.url,
|
||||
args.token,
|
||||
args.device,
|
||||
args.page_size,
|
||||
args.current,
|
||||
args.created_at,
|
||||
args.days_ago
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
elif args.command == "view-console":
|
||||
# View console audits
|
||||
result = view_console_audits(
|
||||
args.url,
|
||||
args.token,
|
||||
args.operator,
|
||||
args.page_size,
|
||||
args.current,
|
||||
args.created_at,
|
||||
args.days_ago
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -95,8 +95,17 @@ def delete(url, token, guid, id):
|
||||
|
||||
def assign(url, token, guid, id, type, value):
|
||||
print("assign", id, type, value)
|
||||
if type != "ab" and type != "strategy_name" and type != "user_name":
|
||||
print("Invalid type, it must be 'ab', 'strategy_name' or 'user_name'")
|
||||
valid_types = [
|
||||
"ab",
|
||||
"strategy_name",
|
||||
"user_name",
|
||||
"device_group_name",
|
||||
"note",
|
||||
"device_username",
|
||||
"device_name",
|
||||
]
|
||||
if type not in valid_types:
|
||||
print(f"Invalid type, it must be one of: {', '.join(valid_types)}")
|
||||
return
|
||||
data = {"type": type, "value": value}
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
@@ -124,7 +133,7 @@ def main():
|
||||
parser.add_argument("--device_group_name", help="Device group name")
|
||||
parser.add_argument(
|
||||
"--assign_to",
|
||||
help="<type>=<value>, e.g. user_name=mike, strategy_name=test, ab=ab1, ab=ab1,tag1",
|
||||
help="<type>=<value>, e.g. user_name=mike, strategy_name=test, device_group_name=group1, note=note1, device_username=username1, device_name=name1, ab=ab1, ab=ab1,tag1,alias1,password1,note1"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--offline_days", type=int, help="Offline duration in days, e.g., 7"
|
||||
@@ -148,28 +157,37 @@ def main():
|
||||
if args.command == "view":
|
||||
for device in devices:
|
||||
print(device)
|
||||
elif args.command == "disable":
|
||||
for device in devices:
|
||||
response = disable(args.url, args.token, device["guid"], device["id"])
|
||||
print(response)
|
||||
elif args.command == "enable":
|
||||
for device in devices:
|
||||
response = enable(args.url, args.token, device["guid"], device["id"])
|
||||
print(response)
|
||||
elif args.command == "delete":
|
||||
for device in devices:
|
||||
response = delete(args.url, args.token, device["guid"], device["id"])
|
||||
print(response)
|
||||
elif args.command == "assign":
|
||||
if "=" not in args.assign_to:
|
||||
print("Invalid assign_to format, it must be <type>=<value>")
|
||||
return
|
||||
type, value = args.assign_to.split("=", 1)
|
||||
for device in devices:
|
||||
response = assign(
|
||||
args.url, args.token, device["guid"], device["id"], type, value
|
||||
)
|
||||
print(response)
|
||||
elif args.command in ["disable", "enable", "delete", "assign"]:
|
||||
# Check if we need user confirmation for multiple devices
|
||||
if len(devices) > 1:
|
||||
print(f"Found {len(devices)} devices. Do you want to proceed with {args.command} operation on the devices? (Y/N)")
|
||||
confirmation = input("Type 'Y' to confirm: ").strip()
|
||||
if confirmation.upper() != 'Y':
|
||||
print("Operation cancelled.")
|
||||
return
|
||||
|
||||
if args.command == "disable":
|
||||
for device in devices:
|
||||
response = disable(args.url, args.token, device["guid"], device["id"])
|
||||
print(response)
|
||||
elif args.command == "enable":
|
||||
for device in devices:
|
||||
response = enable(args.url, args.token, device["guid"], device["id"])
|
||||
print(response)
|
||||
elif args.command == "delete":
|
||||
for device in devices:
|
||||
response = delete(args.url, args.token, device["guid"], device["id"])
|
||||
print(response)
|
||||
elif args.command == "assign":
|
||||
if "=" not in args.assign_to:
|
||||
print("Invalid assign_to format, it must be <type>=<value>")
|
||||
return
|
||||
type, value = args.assign_to.split("=", 1)
|
||||
for device in devices:
|
||||
response = assign(
|
||||
args.url, args.token, device["guid"], device["id"], type, value
|
||||
)
|
||||
print(response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.1
|
||||
Version: 1.4.3
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.1
|
||||
Version: 1.4.3
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.1
|
||||
Version: 1.4.3
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Desktop Entry]
|
||||
Name=RustDeskURL Scheme Handler
|
||||
Name=RustDesk
|
||||
NoDisplay=true
|
||||
MimeType=x-scheme-handler/rustdesk;
|
||||
TryExec=rustdesk
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user