674 Commits

365 changed files with 25208 additions and 10540 deletions

View File

@@ -1,8 +1,14 @@
[target.x86_64-pc-windows-msvc] [target.x86_64-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"] rustflags = ["-Ctarget-feature=+crt-static"]
[target.i686-pc-windows-msvc] [target.i686-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
[target.'cfg(target_os="macos")'] [target.'cfg(target_os="macos")']
rustflags = [ rustflags = [
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null", "-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
] ]
#[target.'cfg(target_os="linux")']
# glibc-static required, this may fix https://github.com/rustdesk/rustdesk/issues/9103, but I do not want this big change
# this is unlikely to help also, because the other so files still use libc dynamically
#rustflags = [
# "-C", "link-args=-Wl,-Bstatic -lc -Wl,-Bdynamic"
#]

View File

@@ -6,7 +6,7 @@ on:
workflow_call: workflow_call:
env: env:
FLUTTER_VERSION: "3.16.9" FLUTTER_VERSION: "3.19.6"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503 RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503

View File

@@ -1,230 +0,0 @@
name: Flutter Nightly MacOS Arm64 Build
on:
#schedule:
# schedule build every night
# - cron: "0/6 * * * *"
workflow_dispatch:
env:
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
CARGO_NDK_VERSION: "3.1.2"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.19.6"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2024.03.25
VCPKG_COMMIT_ID: "a34c873a9717a888f58dc05268dea15592c2f0ff"
VERSION: "1.2.5"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
# To make a custom build with your own servers set the below secret values
RS_PUB_KEY: "${{ secrets.RS_PUB_KEY }}"
RENDEZVOUS_SERVER: "${{ secrets.RENDEZVOUS_SERVER }}"
API_SERVER: "${{ secrets.API_SERVER }}"
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
jobs:
build-appimage:
name: Build image ${{ matrix.job.target }}
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
job:
- {
target: x86_64-unknown-linux-gnu,
arch: x86_64,
}
- {
target: aarch64-unknown-linux-gnu,
arch: aarch64,
}
steps:
- name: Checkout source code
uses: actions/checkout@v3
- name: Rename Binary
run: |
sudo apt-get update -y
sudo apt-get install -y wget libarchive-tools
wget https://github.com/rustdesk/rustdesk/releases/download/nightly/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb rustdesk-${{ env.VERSION }}.deb
- name: Patch archlinux PKGBUILD
if: matrix.job.arch == 'x86_64'
run: |
sed -i "s/x86_64/${{ matrix.job.arch }}/g" res/PKGBUILD
if [[ "${{ matrix.job.arch }}" == "aarch64" ]]; then
sed -i "s/linux\/x64/linux\/arm64/g" ./res/PKGBUILD
fi
bsdtar -zxvf rustdesk-${{ env.VERSION }}.deb
tar -xvf ./data.tar.xz
case ${{ matrix.job.arch }} in
aarch64)
mkdir -p flutter/build/linux/arm64/release/bundle
cp -rf usr/lib/rustdesk/* flutter/build/linux/arm64/release/bundle/
;;
x86_64)
mkdir -p flutter/build/linux/x64/release/bundle
cp -rf usr/lib/rustdesk/* flutter/build/linux/x64/release/bundle/
;;
esac
- name: Build archlinux package
if: matrix.job.arch == 'x86_64'
uses: rustdesk-org/arch-makepkg-action@master
with:
packages: >
llvm
clang
libva
libvdpau
rust
gstreamer
unzip
git
cmake
gcc
curl
wget
nasm
zip
make
pkg-config
clang
gtk3
xdotool
libxcb
libxfixes
alsa-lib
pipewire
python
ttf-arphic-uming
libappindicator-gtk3
pam
gst-plugins-base
gst-plugin-pipewire
scripts: |
cd res && HBB=`pwd`/.. FLUTTER=1 makepkg -f
- name: Publish archlinux package
if: matrix.job.arch == 'x86_64'
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
res/rustdesk-${{ env.VERSION }}*.zst
- name: Build appimage package
shell: bash
run: |
# set-up appimage-builder
pushd /tmp
wget -O appimage-builder-x86_64.AppImage https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage
chmod +x appimage-builder-x86_64.AppImage
sudo mv appimage-builder-x86_64.AppImage /usr/local/bin/appimage-builder
popd
# run appimage-builder
pushd appimage
sudo appimage-builder --skip-tests --recipe ./AppImageBuilder-${{ matrix.job.arch }}.yml
- name: Publish appimage package
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
./appimage/rustdesk-${{ env.VERSION }}-*.AppImage
build-flatpak:
name: Build Flatpak ${{ matrix.job.target }}
runs-on: ${{ matrix.job.on }}
strategy:
fail-fast: false
matrix:
job:
- {
target: x86_64-unknown-linux-gnu,
distro: ubuntu18.04,
on: ubuntu-20.04,
arch: x86_64,
}
- {
target: aarch64-unknown-linux-gnu,
# try out newer flatpak since error of "error: Nothing matches org.freedesktop.Platform in remote flathub"
distro: ubuntu22.04,
on: [self-hosted, Linux, ARM64],
arch: aarch64,
}
steps:
- name: Checkout source code
uses: actions/checkout@v3
- name: Rename Binary
run: |
sudo apt-get update -y
sudo apt-get install -y wget
wget https://github.com/rustdesk/rustdesk/releases/download/nightly/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb rustdesk-${{ env.VERSION }}.deb
- uses: rustdesk-org/run-on-arch-action@amd64-support
name: Build rustdesk flatpak package for ${{ matrix.job.arch }}
id: rpm
with:
arch: ${{ matrix.job.arch }}
distro: ${{ matrix.job.distro }}
githubToken: ${{ github.token }}
setup: |
ls -l "${PWD}"
dockerRunArgs: |
--volume "${PWD}:/workspace"
shell: /bin/bash
install: |
apt-get update -y
apt-get install -y \
curl \
git \
rpm \
wget
run: |
# disable git safe.directory
git config --global --add safe.directory "*"
pushd /workspace
# install
apt-get update -y
apt-get install -y \
cmake \
curl \
flatpak \
flatpak-builder \
gcc \
git \
g++ \
libgtk-3-dev \
nasm \
wget
# flatpak deps
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform/${{ matrix.job.arch }}/23.08
flatpak --user install -y flathub org.freedesktop.Sdk/${{ matrix.job.arch }}/23.08
# package
pushd flatpak
git clone https://github.com/flathub/shared-modules.git --depth=1
flatpak-builder --user --force-clean --repo=repo ./build ./rustdesk.json
flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.flatpak com.rustdesk.RustDesk
- name: Publish flatpak package
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
flatpak/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.flatpak

View File

@@ -4,9 +4,9 @@ env:
# MIN_SUPPORTED_RUST_VERSION: "1.46.0" # MIN_SUPPORTED_RUST_VERSION: "1.46.0"
# CICD_INTERMEDIATES_DIR: "_cicd-intermediates" # CICD_INTERMEDIATES_DIR: "_cicd-intermediates"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2023.10.19 # vcpkg version: 2024.06.15
# for multiarch gcc compatibility # for multiarch gcc compatibility
VCPKG_COMMIT_ID: "8eb57355a4ffb410a2e94c07b4dca2dffbee8e50" VCPKG_COMMIT_ID: "f7423ee180c4b7f40d43402c2feb3859161ef625"
on: on:
workflow_dispatch: workflow_dispatch:
@@ -112,6 +112,8 @@ jobs:
libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \ libgtk-3-dev \
libpulse-dev \ libpulse-dev \
libva-dev \
libvdpau-dev \
libxcb-randr0-dev \ libxcb-randr0-dev \
libxcb-shape0-dev \ libxcb-shape0-dev \
libxcb-xfixes0-dev \ libxcb-xfixes0-dev \

39
.github/workflows/fdroid.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Fdroid version file generation
on:
workflow_dispatch:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+-[0-9]+'
jobs:
# https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.carriez.flutter_hbb.yml
# Finds latest release and transforms F-Droid version code from version as follows:
# X.Y.Z-A => X * 1e6 + Y * 1e4 + Z * 1e2 + A
update-fdroid-version-file:
name: Publish RustDesk version file for F-Droid updater
runs-on: ubuntu-latest
steps:
- name: Generate RustDesk version file
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
UPSTREAM_VERNAME="${GITHUB_REF##refs/tags/}"
UPSTREAM_VERNAME="${UPSTREAM_VERNAME##v}"
else
UPSTREAM_VERNAME="$(curl https://api.github.com/repos/rustdesk/rustdesk/releases/latest | jq -r .tag_name | sed 's/^v//')"
fi
UPSTREAM_VERCODE="$(echo "$UPSTREAM_VERNAME" | tr '.' ' ' | tr '-' ' ' | while read -r MAJOR MINOR PATCH REV; do [ -z "$MAJOR" ] && MAJOR=0; [ -z "$MINOR" ] && MINOR=0; [ -z "$PATCH" ] && PATCH=0; [ -z "$REV" ] && REV=0; echo "$(( 1000000 * $MAJOR + 10000 * $MINOR + 100 * $PATCH + $REV ))"; done)"
echo "versionName=$UPSTREAM_VERNAME" > rustdesk-version.txt
echo "versionCode=$UPSTREAM_VERCODE" >> rustdesk-version.txt
shell: bash
- name: Publish RustDesk version file
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: "fdroid-version"
files: |
./rustdesk-version.txt

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +0,0 @@
name: Flutter Windows History Build
on: [workflow_dispatch]
env:
LLVM_VERSION: "10.0"
FLUTTER_VERSION: "3.16.9"
TAG_NAME: "tmp"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VERSION: "1.2.5"
jobs:
build-for-history-windows:
name: ${{ matrix.job.date }}
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
job:
- { target: x86_64-pc-windows-msvc, os: windows-2022, arch: x86_64, date: 2023-08-04, ref: 72c198a1e94cc1e0242fce88f92b3f3caedcd0c3 }
steps:
- name: Checkout source code
uses: actions/checkout@v4
with:
ref: ${{ matrix.job.ref }}
- name: Install LLVM and Clang
uses: KyleMayes/install-llvm-action@v1
with:
version: ${{ env.LLVM_VERSION }}
- name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: ${{ matrix.job.target }}
override: true
components: rustfmt
profile: minimal # minimal component installation (ie, no documentation)
- name: Install flutter rust bridge deps
run: |
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid"
Push-Location flutter ; flutter pub get ; Pop-Location
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
with:
vcpkgDirectory: C:\vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
- name: Install vcpkg dependencies
run: |
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
shell: bash
- name: Build rustdesk
run: python3 .\build.py --portable --hwcodec --flutter
- name: Build self-extracted executable
shell: bash
run: |
pushd ./libs/portable
python3 ./generate.py -f ../../flutter/build/windows/runner/Release/ -o . -e ../../flutter/build/windows/runner/Release/rustdesk.exe
popd
mkdir -p ./SignOutput
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ matrix.job.date }}-${{ matrix.job.target }}.exe
- name: Publish Release
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
./SignOutput/rustdesk-*.exe

416
.github/workflows/playground.yml vendored Normal file
View File

@@ -0,0 +1,416 @@
name: playground
on:
#schedule:
# schedule build every night
# - cron: "0/6 * * * *"
workflow_dispatch:
env:
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
CARGO_NDK_VERSION: "3.1.2"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.22.2"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2024.06.15
VCPKG_COMMIT_ID: "f7423ee180c4b7f40d43402c2feb3859161ef625"
VERSION: "1.3.2"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
# To make a custom build with your own servers set the below secret values
RS_PUB_KEY: "${{ secrets.RS_PUB_KEY }}"
RENDEZVOUS_SERVER: "${{ secrets.RENDEZVOUS_SERVER }}"
API_SERVER: "${{ secrets.API_SERVER }}"
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
jobs:
build-for-macOS:
name: ${{ matrix.job.target }}
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
job:
- {
target: x86_64-apple-darwin,
os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
extra-build-args: "",
arch: x86_64,
flutter: "3.13.9",
ref: "f6509e3fd6917aa976bad2fc684182601ebf2434",
bridge: "1.80.1",
date: "20231219"
}
- {
target: x86_64-apple-darwin,
os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
extra-build-args: "",
arch: x86_64,
flutter: "3.10.6",
ref: "f6509e3fd6917aa976bad2fc684182601ebf2434",
bridge: "1.80.1",
date: "20231219"
}
- {
target: x86_64-apple-darwin,
os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
extra-build-args: "",
arch: x86_64,
flutter: "3.10.6",
ref: "85ddfc0739f052cab0029c46b899b959ee94eeb8",
bridge: "1.80.1",
date: "20231119"
}
- {
target: x86_64-apple-darwin,
os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
extra-build-args: "",
arch: x86_64,
flutter: "3.13.9",
ref: "85ddfc0739f052cab0029c46b899b959ee94eeb8",
bridge: "1.80.1",
date: "20231119"
}
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@v3
with:
ref: ${{ matrix.job.ref }}
- name: Import the codesign cert
if: env.MACOS_P12_BASE64 != null
uses: apple-actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
keychain: rustdesk
- name: Check sign and import sign key
if: env.MACOS_P12_BASE64 != null
run: |
security default-keychain -s rustdesk.keychain
security find-identity -v
- name: Import notarize key
if: env.MACOS_P12_BASE64 != null
uses: timheuer/base64-to-file@v1.2
with:
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
fileName: rustdesk.json
fileDir: ${{ github.workspace }}
encodedString: ${{ secrets.MACOS_NOTARIZE_JSON }}
- name: Install rcodesign tool
if: env.MACOS_P12_BASE64 != null
shell: bash
run: |
pushd /tmp
wget https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-macos-universal.tar.gz
tar -zxvf apple-codesign-0.22.0-macos-universal.tar.gz
mv apple-codesign-0.22.0-macos-universal/rcodesign /usr/local/bin
popd
- name: Install build runtime
run: |
brew install llvm create-dmg nasm cmake gcc wget ninja pkg-config
- name: Install flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ matrix.job.flutter }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ matrix.job.os }}
- name: Install flutter rust bridge deps
shell: bash
run: |
sed -i '' 's/3.1.0/2.17.0/g' flutter/pubspec.yaml;
cargo install flutter_rust_bridge_codegen --version ${{ matrix.job.bridge }} --features "uuid"
# below works for mac to make buildable on 3.13.9
# pushd flutter/lib; find . -name "*.dart" | xargs -I{} sed -i '' 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g' {}; popd;
pushd flutter && flutter pub get && popd
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
- name: Install vcpkg dependencies
run: |
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
- name: Restore from cache and install vcpkg
uses: lukka/run-vcpkg@v7
if: false
with:
setupOnly: true
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
- name: Install vcpkg dependencies
if: false
run: |
$VCPKG_ROOT/vcpkg install libvpx libyuv opus aom
- name: Show version information (Rust, cargo, Clang)
shell: bash
run: |
clang --version || true
rustup -V
rustup toolchain list
rustup default
cargo -V
rustc -V
- name: Build rustdesk
run: |
./build.py --flutter ${{ matrix.job.extra-build-args }}
- name: create unsigned dmg
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 }}-${{ matrix.job.arch }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
- name: Codesign app and create signed dmg
if: env.MACOS_P12_BASE64 != null
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"
# Unlock keychain
security default-keychain -s rustdesk.keychain
security unlock-keychain -p ${{ secrets.MACOS_P12_PASSWORD }} rustdesk.keychain
# 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 ${{ github.workspace }}/rustdesk.json --staple rustdesk-${{ env.VERSION }}.dmg
- name: Rename rustdesk
run: |
for name in rustdesk*??.dmg; do
mv "$name" "${name%%.dmg}-${{ matrix.job.arch }}-flutter${{ matrix.job.flutter }}-flutter${{ matrix.job.date }}.dmg"
done
- name: Publish DMG package
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
rustdesk*-${{ matrix.job.arch }}*.dmg
build-rustdesk-android:
if: false
name: build rustdesk android apk ${{ matrix.job.target }}
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
job:
- {
arch: aarch64,
target: aarch64-linux-android,
os: ubuntu-20.04,
openssl-arch: android-arm64,
ref: master, # latest
}
steps:
- name: Checkout source code
uses: actions/checkout@v3
with:
ref: ${{ matrix.job.ref }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
clang \
cmake \
curl \
gcc-multilib \
git \
g++ \
g++-multilib \
libayatana-appindicator3-dev\
libasound2-dev \
libc6-dev \
libclang-10-dev \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
libpam0g-dev \
libpulse-dev \
libva-dev \
libvdpau-dev \
libxcb-randr0-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxdo-dev \
libxfixes-dev \
llvm-10-dev \
nasm \
yasm \
ninja-build \
openjdk-11-jdk-headless \
pkg-config \
tree \
wget
- name: Install flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: "rustfmt"
- name: Install flutter rust bridge deps
run: |
git config --global core.longpaths true
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid"
sed -i 's/uni_links_desktop/#uni_links_desktop/g' flutter/pubspec.yaml
pushd flutter/lib; find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g'; popd;
pushd flutter ; flutter pub get ; popd
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
- uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
- name: Install vcpkg dependencies
run: |
case ${{ matrix.job.target }} in
aarch64-linux-android)
./flutter/build_android_deps.sh arm64-v8a
;;
armv7-linux-androideabi)
./flutter/build_android_deps.sh armeabi-v7a
;;
esac
shell: bash
- name: Clone deps
shell: bash
run: |
pushd /opt
git clone https://github.com/rustdesk-org/rustdesk_thirdparty_lib.git --depth=1
ls -ls /opt/artifacts/vcpkg/installed/arm64-android/lib/
# cp -rf /opt/rustdesk_thirdparty_lib/vcpkg/* /opt/artifacts/vcpkg/
ls -ls /opt/artifacts/vcpkg/installed/arm64-android/lib/
- name: Build rustdesk lib
env:
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }}
run: |
rustup target add ${{ matrix.job.target }}
cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }}
case ${{ matrix.job.target }} in
aarch64-linux-android)
./flutter/ndk_arm64.sh
mkdir -p ./flutter/android/app/src/main/jniLibs/arm64-v8a
cp ./target/${{ matrix.job.target }}/release/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so
;;
armv7-linux-androideabi)
./flutter/ndk_arm.sh
mkdir -p ./flutter/android/app/src/main/jniLibs/armeabi-v7a
cp ./target/${{ matrix.job.target }}/release/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/armeabi-v7a/librustdesk.so
;;
esac
- name: Build rustdesk
shell: bash
env:
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64
run: |
export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH
# temporary use debug sign config
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
case ${{ matrix.job.target }} in
aarch64-linux-android)
mkdir -p ./flutter/android/app/src/main/jniLibs/arm64-v8a
cp ${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/
cp ./target/${{ matrix.job.target }}/release/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so
# build flutter
pushd flutter
flutter build apk --release --target-platform android-arm64 --split-per-abi
mv build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
;;
armv7-linux-androideabi)
mkdir -p ./flutter/android/app/src/main/jniLibs/armeabi-v7a
cp ${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/arm-linux-androideabi/libc++_shared.so ./flutter/android/app/src/main/jniLibs/armeabi-v7a/
cp ./target/${{ matrix.job.target }}/release/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/armeabi-v7a/librustdesk.so
# build flutter
pushd flutter
flutter build apk --release --target-platform android-arm --split-per-abi
mv build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
;;
esac
popd
mkdir -p signed-apk; pushd signed-apk
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
- uses: r0adkll/sign-android-release@v1
name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk
with:
releaseDirectory: ./signed-apk
signingKeyBase64: ${{ secrets.ANDROID_SIGNING_KEY }}
alias: ${{ secrets.ANDROID_ALIAS }}
keyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "30.0.2"
- name: Publish signed apk package
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
${{steps.sign-rustdesk.outputs.signedReleaseFile}}

View File

@@ -4,9 +4,9 @@ on:
types: [released] types: [released]
jobs: jobs:
publish: publish:
runs-on: windows-latest # action can only be run on windows runs-on: ubuntu-latest
steps: steps:
- uses: vedantmgoyal2009/winget-releaser@v1 - uses: vedantmgoyal9/winget-releaser@main
with: with:
identifier: RustDesk.RustDesk identifier: RustDesk.RustDesk
version: ${{ github.event.release.tag_name }} version: ${{ github.event.release.tag_name }}

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ examples/**/target/
vcpkg_installed vcpkg_installed
flutter/lib/generated_plugin_registrant.dart flutter/lib/generated_plugin_registrant.dart
libsciter.dylib libsciter.dylib
flutter/web/

2743
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk" name = "rustdesk"
version = "1.2.5" version = "1.3.2"
authors = ["rustdesk <info@rustdesk.com>"] authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021" edition = "2021"
build= "build.rs" build= "build.rs"
@@ -66,7 +66,7 @@ default-net = "0.14"
wol-rs = "1.0" wol-rs = "1.0"
flutter_rust_bridge = { version = "=1.80", features = ["uuid"], optional = true} flutter_rust_bridge = { version = "=1.80", features = ["uuid"], optional = true}
errno = "0.3" errno = "0.3"
rdev = { git = "https://github.com/fufesou/rdev" } rdev = { git = "https://github.com/rustdesk-org/rdev" }
url = { version = "2.3", features = ["serde"] } url = { version = "2.3", features = ["serde"] }
crossbeam-queue = "0.3" crossbeam-queue = "0.3"
hex = "0.4" hex = "0.4"
@@ -89,7 +89,10 @@ sys-locale = "0.3"
enigo = { path = "libs/enigo", features = [ "with_serde" ] } enigo = { path = "libs/enigo", features = [ "with_serde" ] }
clipboard = { path = "libs/clipboard" } clipboard = { path = "libs/clipboard" }
ctrlc = "3.2" ctrlc = "3.2"
arboard = { git = "https://github.com/fufesou/arboard", branch = "feat/x11_set_conn_timeout", features = ["wayland-data-control"] } # arboard = { version = "3.4.0", features = ["wayland-data-control"] }
arboard = { git = "https://github.com/rustdesk-org/arboard", features = ["wayland-data-control"] }
clipboard-master = { git = "https://github.com/rustdesk-org/clipboard-master" }
system_shutdown = "4.0" system_shutdown = "4.0"
qrcode-generator = "4.1" qrcode-generator = "4.1"
@@ -111,7 +114,7 @@ winapi = { version = "0.3", features = [
winreg = "0.11" winreg = "0.11"
windows-service = "0.6" windows-service = "0.6"
virtual_display = { path = "libs/virtual_display" } virtual_display = { path = "libs/virtual_display" }
impersonate_system = { git = "https://github.com/21pages/impersonate-system" } impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
shared_memory = "0.12" shared_memory = "0.12"
tauri-winrt-notification = "0.1.2" tauri-winrt-notification = "0.1.2"
runas = "1.2" runas = "1.2"
@@ -135,7 +138,7 @@ image = "0.24"
keepawake = { git = "https://github.com/rustdesk-org/keepawake-rs" } keepawake = { git = "https://github.com/rustdesk-org/keepawake-rs" }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
wallpaper = { git = "https://github.com/21pages/wallpaper.rs" } wallpaper = { git = "https://github.com/rustdesk-org/wallpaper.rs" }
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] [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 # https://github.com/rustdesk/rustdesk-server-pro/issues/189, using native-tls for better tls support
@@ -149,21 +152,23 @@ psimple = { package = "libpulse-simple-binding", version = "2.27" }
pulse = { package = "libpulse-binding", version = "2.27" } pulse = { package = "libpulse-binding", version = "2.27" }
rust-pulsectl = { git = "https://github.com/open-trade/pulsectl" } rust-pulsectl = { git = "https://github.com/open-trade/pulsectl" }
async-process = "1.7" async-process = "1.7"
mouce = { git="https://github.com/fufesou/mouce.git" } evdev = { git="https://github.com/rustdesk-org/evdev" }
evdev = { git="https://github.com/fufesou/evdev" }
dbus = "0.9" dbus = "0.9"
dbus-crossroads = "0.5" dbus-crossroads = "0.5"
pam = { git="https://github.com/fufesou/pam" } pam = { git="https://github.com/rustdesk-org/pam" }
users = { version = "0.11" } users = { version = "0.11" }
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
percent-encoding = {version = "2.3", optional = true} percent-encoding = {version = "2.3", optional = true}
once_cell = {version = "1.18", optional = true} once_cell = {version = "1.18", optional = true}
nix = { version = "0.29", features = ["term", "process"]}
gtk = "0.18"
termios = "0.3"
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.13" android_logger = "0.13"
jni = "0.21" jni = "0.21"
android-wakelock = { git = "https://github.com/21pages/android-wakelock" } android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
[workspace] [workspace]
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable"] members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable"]

View File

@@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<img src="res/logo-header.svg" alt="RustDesk - Your remote desktop"><br> <img src="res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
<a href="#free-public-servers">Servers</a> • <a href="#public-servers">Servers</a> •
<a href="#raw-steps-to-build">Build</a> • <a href="#raw-steps-to-build">Build</a> •
<a href="#how-to-build-with-docker">Docker</a> • <a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> • <a href="#file-structure">Structure</a> •
@@ -59,19 +59,19 @@ Please download Sciter dynamic library yourself.
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -171,3 +171,7 @@ Please ensure that you are running these commands from the root of the RustDesk
![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) ![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) ![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)
## [Public Servers](#public-servers)
RustDesk is supported by a free EU server, graciously provided by [Codext GmbH](https://codext.link/rustdesk?utm_source=github)

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.2.5 version: 1.3.2
exec: usr/lib/rustdesk/rustdesk exec: usr/lib/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.2.5 version: 1.3.2
exec: usr/lib/rustdesk/rustdesk exec: usr/lib/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:
@@ -37,6 +37,9 @@ AppDir:
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal-security main restricted - sourceline: deb http://archive.ubuntu.com/ubuntu/ focal-security main restricted
universe multiverse universe multiverse
include: include:
# https://github.com/rustdesk/rustdesk/issues/9103
# Because of APPDIR_LIBRARY_PATH, this libc6 is not used, use LD_PRELOAD: $APPDIR/usr/lib/x86_64-linux-gnu/libc.so.6 may help, If you have time, please have a try.
# We modify APPDIR_LIBRARY_PATH to use system lib first because gst crashed if not doing so, but you can try to change it.
- libc6:amd64 - libc6:amd64
- libgtk-3-0 - libgtk-3-0
- libxcb-randr0 - libxcb-randr0

View File

@@ -25,12 +25,17 @@ flutter_build_dir_2 = f'flutter/{flutter_build_dir}'
skip_cargo = False skip_cargo = False
def get_arch() -> str: def get_deb_arch() -> str:
custom_arch = os.environ.get("ARCH") custom_arch = os.environ.get("DEB_ARCH")
if custom_arch is None: if custom_arch is None:
return "amd64" return "amd64"
return custom_arch return custom_arch
def get_deb_extra_depends() -> str:
custom_arch = os.environ.get("DEB_ARCH")
if custom_arch == "armhf": # for arm32v7 libsciter-gtk.so
return ", libatomic1"
return ""
def system2(cmd): def system2(cmd):
exit_code = os.system(cmd) exit_code = os.system(cmd)
@@ -48,15 +53,7 @@ def get_version():
def parse_rc_features(feature): def parse_rc_features(feature):
available_features = { available_features = {}
'PrivacyMode': {
'platform': ['windows'],
'zip_url': 'https://github.com/fufesou/RustDeskTempTopMostWindow/releases/download/v0.3'
'/TempTopMostWindow_x64.zip',
'checksum_url': 'https://github.com/fufesou/RustDeskTempTopMostWindow/releases/download/v0.3/checksum_md5',
'include': ['WindowInjection.dll'],
}
}
apply_features = {} apply_features = {}
if not feature: if not feature:
feature = [] feature = []
@@ -81,7 +78,6 @@ def parse_rc_features(feature):
elif isinstance(feature, list): elif isinstance(feature, list):
if windows: if windows:
# download third party is deprecated, we use github ci instead. # download third party is deprecated, we use github ci instead.
# force add PrivacyMode
# feature.append('PrivacyMode') # feature.append('PrivacyMode')
pass pass
for feat in feature: for feat in feature:
@@ -108,7 +104,7 @@ def make_parser():
nargs='+', nargs='+',
default='', default='',
help='Integrate features, windows only.' help='Integrate features, windows only.'
'Available: PrivacyMode. Special value is "ALL" and empty "". Default is empty.') 'Available: [Not used for now]. Special value is "ALL" and empty "". Default is empty.')
parser.add_argument('--flutter', action='store_true', parser.add_argument('--flutter', action='store_true',
help='Build flutter package', default=False) help='Build flutter package', default=False)
parser.add_argument( parser.add_argument(
@@ -287,14 +283,17 @@ def generate_control_file(version):
system2('/bin/rm -rf %s' % control_file_path) system2('/bin/rm -rf %s' % control_file_path)
content = """Package: rustdesk content = """Package: rustdesk
Section: net
Priority: optional
Version: %s Version: %s
Architecture: %s Architecture: %s
Maintainer: rustdesk <info@rustdesk.com> Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com Homepage: https://rustdesk.com
Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva-drm2, libva-x11-2, libvdpau1, libgstreamer-plugins-base1.0-0, libpam0g, libappindicator3-1, gstreamer1.0-pipewire Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva-drm2, libva-x11-2, libvdpau1, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1
Description: A remote control software. Description: A remote control software.
""" % (version, get_arch()) """ % (version, get_deb_arch(), get_deb_extra_depends())
file = open(control_file_path, "w") file = open(control_file_path, "w")
file.write(content) file.write(content)
file.close() file.close()
@@ -334,8 +333,6 @@ def build_flutter_deb(version, features):
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') 'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2( system2(
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
'cp ../res/com.rustdesk.RustDesk.policy tmpdeb/usr/share/polkit-1/actions/')
system2( system2(
'cp ../res/startwm.sh tmpdeb/etc/rustdesk/') 'cp ../res/startwm.sh tmpdeb/etc/rustdesk/')
system2( system2(
@@ -379,8 +376,6 @@ def build_deb_from_folder(version, binary_folder):
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') 'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2( system2(
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
'cp ../res/com.rustdesk.RustDesk.policy tmpdeb/usr/share/polkit-1/actions/')
system2( system2(
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")

View File

@@ -0,0 +1,87 @@
# 贡献者公约行为准则
## 我们的承诺
身为社区成员、贡献者和领袖,我们承诺使社区参与者不受骚扰,无论其年龄、体型、可见或不可见的缺陷、族裔、性征、性别认同和表达、经验水平、教育程度、社会与经济地位、国籍、相貌、种族、种姓、肤色、宗教信仰、性倾向或性取向如何。
我们承诺以有助于建立开放、友善、多样化、包容、健康社区的方式行事和互动。
## 我们的标准
有助于为我们的社区创造积极环境的行为例子包括但不限于:
* 表现出对他人的同情和善意
* 尊重不同的主张、观点和感受
* 提出和大方接受建设性意见
* 承担责任并向受我们错误影响的人道歉
* 注重社区共同诉求,而非个人得失
不当行为例子包括:
* 使用情色化的语言或图像,及性引诱或挑逗
* 嘲弄、侮辱或诋毁性评论,以及人身或政治攻击
* 公开或私下的骚扰行为
* 未经他人明确许可,公布他人的私人信息,如物理或电子邮件地址
* 其他有理由认定为违反职业操守的不当行为
## 责任和权力
社区领袖有责任解释和落实我们所认可的行为准则,并妥善公正地对他们认为不当、威胁、冒犯或有害的任何行为采取纠正措施。
社区领导有权力和责任删除、编辑或拒绝或拒绝与本行为准则不相符的评论comment、提交commits、代码、维基wiki编辑、议题issues或其他贡献并在适当时机知采取措施的理由。
## 适用范围
本行为准则适用于所有社区场合,也适用于在公共场所代表社区时的个人。
代表社区的情形包括使用官方电子邮件地址、通过官方社交媒体帐户发帖或在线上或线下活动中担任指定代表。
## 监督
辱骂、骚扰或其他不可接受的行为可通过[info@rustdesk.com](mailto:info@rustdesk.com)向负责监督的社区领袖报告。 所有投诉都将得到及时和公平的审查和调查。
所有社区领袖都有义务尊重任何事件报告者的隐私和安全。
## 处理方针
社区领袖将遵循下列社区处理方针来明确他们所认定违反本行为准则的行为的处理方式:
### 1. 纠正
**社区影响**: 使用不恰当的语言或其他在社区中被认定为不符合职业道德或不受欢迎的行为。
**处理意见**: 由社区领袖发出非公开的书面警告,明确说明违规行为的性质,并解释举止如何不妥。或将要求公开道歉。
### 2. 警告
**社区影响**: 单个或一系列违规行为。
**处理意见**: 警告并对连续性行为进行处理。在指定时间内,不得与相关人员互动,包括主动与行为准则执行者互动。这包括避免在社区场所和外部渠道中的互动。违反这些条款可能会导致临时或永久封禁。
### 3. 临时封禁
**社区影响**: 严重违反社区准则,包括持续的不当行为。
**处理意见**: 在指定时间内,暂时禁止与社区进行任何形式的互动或公开交流。在此期间,不得与相关人员进行公开或私下互动,包括主动与行为准则执行者互动。违反这些条款可能会导致永久封禁。
### 4. 永久封禁
**社区影响**: 行为模式表现出违反社区准则,包括持续的不当行为、骚扰个人或攻击或贬低某个类别的个体。
**处理意见**: 永久禁止在社区内进行任何形式的公开互动。
## 参见
本行为准则改编自[参与者公约][homepage]2.0 版, 参见
[https://www.contributor-covenant.org/zh-cn/version/2/0/code_of_conduct.html][v2.0].
指导方针借鉴自[Mozilla纪检分级][Mozilla CoC].
有关本行为准则的常见问题的答案,参见 [https://www.contributor-covenant.org/faq][FAQ]。 其他语言翻译参见[https://www.contributor-covenant.org/translations][translations]。
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/zh-cn/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

32
docs/CONTRIBUTING-ZH.md Normal file
View File

@@ -0,0 +1,32 @@
# 为RustDesk做贡献
Rust欢迎每一位贡献者如果您有意向为我们做出贡献请遵循以下指南
## 贡献方式
对 RustDesk 或其依赖项的贡献需要通过 GitHub 的 Pull Request (PR) 的形式提交。每个 PR 都会由核心贡献者(即有权限合并代码的人)进行审核,审核通过后代码会合并到主分支,或者您会收到需要修改的反馈。所有贡献者,包括核心贡献者,提交的代码都应遵循此流程。
如果您希望处理某个问题,请先在对应的 GitHub issue 下发表评论,声明您将处理该问题,以避免该问题被多位贡献者重复处理。
## PR 注意事项
- 从 master 分支创建一个新的分支并在提交PR之前如果需要将您的分支 变基(rebase) 到最新的 master 分支。如果您的分支无法顺利合并到 master 分支,您可能会被要求更新您的代码。
- 每次提交的改动应该尽可能少,并且要保证每次提交的代码都是正确的(即每个 commit 都应能成功编译并通过测试)。
- 每个提交都应附有开发者证书签名(http://developercertificate.org), 表明您(以及您的雇主,若适用)同意遵守项目[许可证条款](../LICENCE)。在使用 git 提交代码时,可以通过在 `git commit` 时使用 `-s` 选项加入签名
- 如果您的 PR 未被及时审核,或需要指定的人员进行审核,您可以通过在 PR 或评论中 @ 提到相关审核者,以及发送[电子邮件](mailto:info@rustdesk.com)的方式请求审核。
- 请为修复的 bug 或新增的功能添加相应的测试用例。
有关具体的 git 使用说明,请参考[GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
## 行为准则
请遵守项目的[贡献者公约行为准则](./CODE_OF_CONDUCT-ZH.md)。
## 沟通渠道
RustDesk 的贡献者主要通过 [Discord](https://discord.gg/nDceKgxnkV) 进行交流。

View File

@@ -1,60 +1,73 @@
<p align="center"> <p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br> <img src="../res/logo-header.svg" alt="RustDesk - あなたのためのリモートデスクトップ"><br>
<a href="#free-public-servers">Servers</a> • <a href="#free-public-servers">Servers</a> •
<a href="#raw-steps-to-build">Build</a> • <a href="#raw-steps-to-build">Build</a> •
<a href="#how-to-build-with-docker">Docker</a> • <a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> • <a href="#file-structure">Structure</a> •
<a href="#snapshot">Snapshot</a><br> <a href="#snapshot">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-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-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="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>]<br>
<b>このREADMEをあなたの母国語に翻訳するために、あなたの助けが必要です。</b> <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 Doc</a>の翻訳者を歓迎します!</b>
</p> </p>
Chat with us: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) 私たちと話す: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09)
Rustで書かれた、設定不要ですぐに使えるリモートデスクトップソフトウェアです。自分のデータを完全にコントロールでき、セキュリティの心配もありません。私たちのランデブー/リレーサーバを使うことも、[自分で設定する](https://rustdesk.com/server) ことも、 [自分でランデブー/リレーサーバを書くこともできます](https://github.com/rustdesk/rustdesk-server-demo)。 Rustで書かれた、設定不要ですぐに使えるリモートデスクトップソフトウェアです。自分のデータを完全にコントロールでき、セキュリティの心配もありません。私たちのランデブー/リレーサーバを使うことも、[自分でサーバーをセットアップする](https://rustdesk.com/server) ことも、 [自分でランデブー/リレーサーバを作成する](https://github.com/rustdesk/rustdesk-server-demo)こともできます
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
RustDeskは誰からの貢献歓迎します。 貢献するには [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) を参照してください。 RustDeskは皆さんの貢献歓迎します。
貢献の方法については[CONTRIBUTING.md](docs/CONTRIBUTING.md)をご確認ください。
[**RustDeskはどの様に動くのか?**](https://github.com/rustdesk/rustdesk/wiki/How-does-RustDesk-work%3F) [**よくある質問**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
[**BINARY DOWNLOAD**](https://github.com/rustdesk/rustdesk/releases) [**パッケージのダウンロード**](https://github.com/rustdesk/rustdesk/releases)
[**ナイトリービルド**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="F-Droidで入手する"
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
## 依存関係 ## 依存関係
デスクトップ版ではGUIに [sciter](https://sciter.com/) が使われています。 sciter dynamic library をダウンロードしてください。 デスクトップ版ではGUIにFlutterまたはSciter(非推奨)を使用しますが、チュートリアルでは分かりやすく、簡単なSciterのみを対象に解説しています。Flutterでのビルド方法については[CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)をご覧ください。
Sciter dynamic libraryを事前にダウンロードしてください。
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) [macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
モバイル版はFlutterを利用します。デスクトップ版もSciterからFlutterへマイグレーション予定です。
## ビルド手順 ## ビルド手順
- Rust開発環境とC ++ビルド環境を準備します - Rust開発環境とC++ビルド環境を準備します
- [vcpkg](https://github.com/microsoft/vcpkg), をインストールし、 `VCPKG_ROOT` 環境変数を正しく設定します。 - [vcpkg](https://github.com/microsoft/vcpkg)をインストールし、環境変数に`VCPKG_ROOT`設定します。
その後、以下のコマンドを実行します。
- 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
- run `cargo run`
- 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
- `cargo run`を実行します。
## [ビルド](https://rustdesk.com/docs/en/dev/build/) ## [ビルド](https://rustdesk.com/docs/en/dev/build/)
## Linuxでのビルド手順 ## Linuxでのビルド方法
### Ubuntu 18 (Debian 10) ### Ubuntu 18 (Debian 10)
```sh ```sh
sudo apt install -y g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
@@ -69,7 +82,7 @@ sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
``` ```
### Install vcpkg ### vcpkgのインストール
```sh ```sh
git clone https://github.com/microsoft/vcpkg git clone https://github.com/microsoft/vcpkg
@@ -81,7 +94,7 @@ export VCPKG_ROOT=$HOME/vcpkg
vcpkg/vcpkg install libvpx libyuv opus aom vcpkg/vcpkg install libvpx libyuv opus aom
``` ```
### Fix libvpx (For Fedora) ### libvpxの修正 (Fedoraのみ)
```sh ```sh
cd vcpkg/buildtrees/libvpx/src cd vcpkg/buildtrees/libvpx/src
@@ -107,9 +120,9 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run VCPKG_ROOT=$HOME/vcpkg cargo run
``` ```
## Dockerでビルドする方法 ## Dockerでビルド方法
リポジトリクローンを作成し、Dockerコンテナを構築することから始めます リポジトリクローンし、Dockerコンテナを構築ます:
```sh ```sh
git clone https://github.com/rustdesk/rustdesk git clone https://github.com/rustdesk/rustdesk
@@ -117,44 +130,50 @@ cd rustdesk
docker build -t "rustdesk-builder" . docker build -t "rustdesk-builder" .
``` ```
その後、アプリケーションをビルドする必要があるたびに、以下のコマンドを実行します 以下のコマンドを実行します:
```sh ```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 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
``` ```
このコマンドはRustDeskをビルドする度に実行する必要があります。
なお、最初のビルドでは、依存関係がキャッシュされるまで時間がかかることがありますが、その後のビルドではより速くなります。さらに、ビルドコマンドに別の引数を指定する必要がある場合は、コマンドの最後にある `<OPTIONAL-ARGS>` の位置で指定することができます。例えば、最適化されたリリースバージョンをビルドしたい場合は、上記のコマンドの後に 初回ビルドは時間がかかるかもしれませんが、2回目以降は依存関係がキャッシュされるため、ビルドにかかる時間が短くなります。
`--release` を実行します。できあがった実行ファイルはシステムのターゲットフォルダに格納され、のコマンドで実行できます。 ビルドコマンドに追加の引数を指定する必要がある場合は、コマンドの最後(`<OPTIONAL-ARGS>`の位置)で指定することができます。例えば、最適化されたリリースバージョンをビルドしたい場合は、上記のコマンドの後に `--release`追記し実行します。ビルドされた実行ファイルはあなたのシステムのターゲットフォルダに保存され、下記のコマンドで実行することができます。
デバッグビルドを起動する場合:
```sh ```sh
target/debug/rustdesk target/debug/rustdesk
``` ```
あるいは、リリース用の実行ファイルを実行している場合: リリースビルドを起動する場合:
```sh ```sh
target/release/rustdesk target/release/rustdesk
``` ```
これらのコマンドをRustDeskリポジトリのルートから実行していることを確認してください。そうしないと、アプリケーションが必要なリソースを見つけられない可能性があります。また、 `install``run` などの他の cargo サブコマンドは、ホストではなくコンテナ内プログラムをインストールまたは実行するため、現在の方法ではサポートされていないことに注意してください コマンドをRustDeskリポジトリのルートから実行していることを確認してください。また、`install``run` などの他のcargoサブコマンドは、ホストではなくコンテナ内プログラムをインストール実行するため、現在の方法ではサポートされていません
## ファイル構造 ## ファイル構造
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: ビデオコーデック、コンフィグ、tcp/udpラッパー、protobuf、ファイル転送用のfs関数その他のユーティリティ関数 - **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: ビデオコーデック、設定、tcp/udpラッパー、protobuf、ファイル転送に利用されるfs関数その他のユーティリティ関数
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: スクリーンキャプチャ - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: スクリーンキャプチャ
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: プラットフォーム固有のキーボード/マウスコントロール - **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: プラットフォーム固有のキーボード/マウス操作
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI - **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Windows、Linux、macOS向けのファイルのコピーと貼り付けの実装
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: オーディオ/クリップボード/入力/ビデオサービス、ネットワーク接続 - **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: 廃止された Sciter UI (非推奨)
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**:
オーディオ/クリップボード/入力/ビデオ サービスとネットワーク接続
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: ピア接続の開始 - **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: ピア接続の開始
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server), と通信し、リモートダイレクト (TCP hole punching) または中継接続を待つ - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript
## スナップショット ## スクリーンショット
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png) ![Connected to a Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) ![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png) ![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)

View File

@@ -1,20 +1,18 @@
<p align="center"> <p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Ваша віддалена стільниця"><br> <img src="../res/logo-header.svg" alt="RustDesk - Ваша віддалена стільниця"><br>
<a href="#безкоштовні-загальнодоступні-сервери">Сервери</a> • <a href="#публічні-сервери">Сервери</a> •
<a href="#кроки-для-збірки">Збирання</a> • <a href="#кроки-для-збірки">Збирання</a> •
<a href="#як-зібрати-за-допомогою-docker">Docker</a> • <a href="#як-зібрати-за-допомогою-docker">Docker</a> •
<a href="#структура-файлів">Структура</a> • <a href="#структура-файлів">Структура</a> •
<a href="#знімки">Знімки</a><br> <a href="#знімки-екрана">Знімки екрана</a><br>
[<a href="../README.md">English</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>]<br> [<a href="../README.md">English</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>]<br>
<b>Нам потрібна ваша допомога для перекладу цього README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">інтерфейсу</a> та <a href="https://github.com/rustdesk/doc.rustdesk.com">документації</a> RustDesk на вашу рідну мову</B> <b>Нам потрібна ваша допомога для перекладу цього README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">інтерфейсу</a> та <a href="https://github.com/rustdesk/doc.rustdesk.com">документації</a> RustDesk вашою рідною мовою</B>
</p> </p>
Спілкування з нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) Спілкування з нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09)
[![Open Bounties](https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Frustdesk%2Fbounties%3Fstatus%3Dopen)](https://console.algora.io/org/rustdesk/bounties?status=open)
Ще один застосунок для віддаленого керування стільницею, написаний на Rust. Працює з коробки, не потребує налаштування. Ви повністю контролюєте свої дані, не турбуючись про безпеку. Ви можете використовувати наш сервер ретрансляції, [налаштувати свій власний](https://rustdesk.com/server), або [написати свій власний сервер ретрансляції](https://github.com/rustdesk/rustdesk-server-demo). Ще один застосунок для віддаленого керування стільницею, написаний на Rust. Працює з коробки, не потребує налаштування. Ви повністю контролюєте свої дані, не турбуючись про безпеку. Ви можете використовувати наш сервер ретрансляції, [налаштувати свій власний](https://rustdesk.com/server), або [написати свій власний сервер ретрансляції](https://github.com/rustdesk/rustdesk-server-demo).
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
@@ -61,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -158,18 +156,22 @@ target/release/rustdesk
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: реалізація копіювання та вставлення файлів для Windows, Linux, macOS. - **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: реалізація копіювання та вставлення файлів для Windows, Linux, macOS.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графічний інтерфейс користувача - **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графічний інтерфейс користувача
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервіси аудіо/буфера обміну/вводу/відео та мережевих підключень - **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервіси аудіо/буфера обміну/вводу/відео та мережевих підключень
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: однорангове з'єднання - **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: однорангове зʼєднання
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого з'єднання - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для Flutter веб клієнту - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для веб клієнта на Flutter
## Знімки ## Знімки екрана
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![Менеджер зʼєднань](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png) ![Підключення до ПК з Windows](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) ![Передача файлів](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png) ![Тунелювання TCP](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)
## [Публічні сервери](#публічні-сервери)
RustDesk підтримується безкоштовним європейським сервером, любʼязно наданим [Codext GmbH](https://codext.link/rustdesk?utm_source=github)

View File

@@ -8,7 +8,7 @@
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br> [<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
</p> </p>
Chat with us: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [Reddit](https://www.reddit.com/r/rustdesk) 与我们交流: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [Reddit](https://www.reddit.com/r/rustdesk)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09)
@@ -18,7 +18,7 @@ Chat with us: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https:
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
RustDesk 期待各位的贡献. 如何参与开发? 详情请看 [CONTRIBUTING.md](CONTRIBUTING.md). RustDesk 期待各位的贡献. 如何参与开发? 详情请看 [CONTRIBUTING-ZH.md](CONTRIBUTING-ZH.md).
[**FAQ**](https://github.com/rustdesk/rustdesk/wiki/FAQ) [**FAQ**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
@@ -32,7 +32,9 @@ RustDesk 期待各位的贡献. 如何参与开发? 详情请看 [CONTRIBUTING.m
## 依赖 ## 依赖
桌面版本界面使用[sciter](https://sciter.com/), 请自行下载 桌面版本使用 Flutter 或 Sciter已弃用作为 GUI本教程仅适用于 Sciter因为它更简单且更易于上手。查看我们的[CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)以构建 Flutter 版本
请自行下载Sciter动态库。
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
@@ -207,12 +209,13 @@ target/release/rustdesk
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 视频编解码, 配置, tcp/udp 封装, protobuf, 文件传输相关文件系统操作函数, 以及一些其他实用函数 - **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 视频编解码, 配置, tcp/udp 封装, protobuf, 文件传输相关文件系统操作函数, 以及一些其他实用函数
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 屏幕截取 - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 屏幕截取
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: 平台相关的鼠标键盘输入 - **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: 平台相关的鼠标键盘输入
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI - **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Windows、Linux、macOS 的文件复制和粘贴实现
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: 过时的 Sciter UI已弃用
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: 被控端服务音频、剪切板、输入、视频服务、网络连接的实现 - **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: 被控端服务音频、剪切板、输入、视频服务、网络连接的实现
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: 控制端 - **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: 控制端
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继) - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继)
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码 - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 移动版本的Flutter代码 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码 - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码
## 截图 ## 截图

View File

@@ -106,5 +106,6 @@ dependencies {
implementation "androidx.media:media:1.6.0" implementation "androidx.media:media:1.6.0"
implementation 'com.github.getActivity:XXPermissions:18.5' implementation 'com.github.getActivity:XXPermissions:18.5'
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("$kotlin_version") } } implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("$kotlin_version") } }
implementation 'com.caverock:androidsvg-aar:1.4'
} }

View File

@@ -81,6 +81,11 @@
android:name=".MainService" android:name=".MainService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="mediaProjection" /> android:foregroundServiceType="mediaProjection" />
<service
android:name=".FloatingWindowService"
android:enabled="true" />
<!-- <!--
Don't delete the meta-data below. Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java This is used by the Flutter tool to generate GeneratedPluginRegistrant.java

View File

@@ -116,24 +116,20 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
} }
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean { fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { if (!isSupportVoiceCall()) {
return false return false
} }
if (isVideoStart() || isAudioStart()) { // No need to check if video or audio is started here.
if (!switchToVoiceCall(mediaProjection)) { if (!switchToVoiceCall(mediaProjection)) {
return false return false
} }
} else {
if (!switchToVoiceCall(mediaProjection)) {
return false
}
}
return true return true
} }
fun onVoiceCallClosed(mediaProjection: MediaProjection?): Boolean { fun onVoiceCallClosed(mediaProjection: MediaProjection?): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { // Return true if not supported, because is was not started.
return false if (!isSupportVoiceCall()) {
return true
} }
if (isVideoStart()) { if (isVideoStart()) {
switchOutVoiceCall(mediaProjection) switchOutVoiceCall(mediaProjection)
@@ -180,9 +176,6 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
} }
fun tryReleaseAudio() { fun tryReleaseAudio() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
if (isAudioStart() || isVideoStart()) { if (isAudioStart() || isVideoStart()) {
return return
} }

View File

@@ -0,0 +1,378 @@
package com.carriez.flutter_hbb
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.PixelFormat
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.ImageView
import android.widget.PopupMenu
import com.caverock.androidsvg.SVG
import ffi.FFI
import kotlin.math.abs
class FloatingWindowService : Service(), View.OnTouchListener {
private lateinit var windowManager: WindowManager
private lateinit var layoutParams: WindowManager.LayoutParams
private lateinit var floatingView: ImageView
private lateinit var originalDrawable: Drawable
private lateinit var leftHalfDrawable: Drawable
private lateinit var rightHalfDrawable: Drawable
private var dragging = false
private var lastDownX = 0f
private var lastDownY = 0f
private var viewCreated = false;
private var keepScreenOn = KeepScreenOn.DURING_CONTROLLED
companion object {
private val logTag = "floatingService"
private var firstCreate = true
private var viewWidth = 120
private var viewHeight = 120
private const val MIN_VIEW_SIZE = 32 // size 0 does not help prevent the service from being killed
private const val MAX_VIEW_SIZE = 320
private var viewUntouchable = false
private var viewTransparency = 1f // 0 means invisible but can help prevent the service from being killed
private var customSvg = ""
private var lastLayoutX = 0
private var lastLayoutY = 0
private var lastOrientation = Configuration.ORIENTATION_UNDEFINED
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onCreate() {
super.onCreate()
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
try {
if (firstCreate) {
firstCreate = false
onFirstCreate(windowManager)
}
Log.d(logTag, "floating window size: $viewWidth x $viewHeight, transparency: $viewTransparency, lastLayoutX: $lastLayoutX, lastLayoutY: $lastLayoutY, customSvg: $customSvg")
createView(windowManager)
handler.postDelayed(runnable, 1000)
Log.d(logTag, "onCreate success")
} catch (e: Exception) {
Log.d(logTag, "onCreate failed: $e")
}
}
override fun onDestroy() {
super.onDestroy()
if (viewCreated) {
windowManager.removeView(floatingView)
}
handler.removeCallbacks(runnable)
}
@SuppressLint("ClickableViewAccessibility")
private fun createView(windowManager: WindowManager) {
floatingView = ImageView(this)
viewCreated = true
originalDrawable = resources.getDrawable(R.drawable.floating_window, null)
if (customSvg.isNotEmpty()) {
try {
val svg = SVG.getFromString(customSvg)
Log.d(logTag, "custom svg info: ${svg.documentWidth} x ${svg.documentHeight}");
// This make the svg render clear
svg.documentWidth = viewWidth * 1f
svg.documentHeight = viewHeight * 1f
originalDrawable = svg.renderToPicture().let {
BitmapDrawable(
resources,
Bitmap.createBitmap(it.width, it.height, Bitmap.Config.ARGB_8888)
.also { bitmap ->
it.draw(Canvas(bitmap))
})
}
floatingView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
Log.d(logTag, "custom svg loaded")
} catch (e: Exception) {
e.printStackTrace()
}
}
val originalBitmap = Bitmap.createBitmap(
originalDrawable.intrinsicWidth,
originalDrawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(originalBitmap)
originalDrawable.setBounds(
0,
0,
originalDrawable.intrinsicWidth,
originalDrawable.intrinsicHeight
)
originalDrawable.draw(canvas)
val leftHalfBitmap = Bitmap.createBitmap(
originalBitmap,
0,
0,
originalDrawable.intrinsicWidth / 2,
originalDrawable.intrinsicHeight
)
val rightHalfBitmap = Bitmap.createBitmap(
originalBitmap,
originalDrawable.intrinsicWidth / 2,
0,
originalDrawable.intrinsicWidth / 2,
originalDrawable.intrinsicHeight
)
leftHalfDrawable = BitmapDrawable(resources, leftHalfBitmap)
rightHalfDrawable = BitmapDrawable(resources, rightHalfBitmap)
floatingView.setImageDrawable(rightHalfDrawable)
floatingView.setOnTouchListener(this)
floatingView.alpha = viewTransparency * 1f
var flags = FLAG_LAYOUT_IN_SCREEN or FLAG_NOT_TOUCH_MODAL or FLAG_NOT_FOCUSABLE
if (viewUntouchable || viewTransparency == 0f) {
flags = flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
}
layoutParams = WindowManager.LayoutParams(
viewWidth / 2,
viewHeight,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY else WindowManager.LayoutParams.TYPE_PHONE,
flags,
PixelFormat.TRANSLUCENT
)
layoutParams.gravity = Gravity.TOP or Gravity.START
layoutParams.x = lastLayoutX
layoutParams.y = lastLayoutY
val keepScreenOnOption = FFI.getLocalOption("keep-screen-on").lowercase()
keepScreenOn = when (keepScreenOnOption) {
"never" -> KeepScreenOn.NEVER
"service-on" -> KeepScreenOn.SERVICE_ON
else -> KeepScreenOn.DURING_CONTROLLED
}
Log.d(logTag, "keepScreenOn option: $keepScreenOnOption, value: $keepScreenOn")
updateKeepScreenOnLayoutParams()
windowManager.addView(floatingView, layoutParams)
moveToScreenSide()
}
private fun onFirstCreate(windowManager: WindowManager) {
val wh = getScreenSize(windowManager)
val w = wh.first
val h = wh.second
// size
FFI.getLocalOption("floating-window-size").let {
if (it.isNotEmpty()) {
try {
val size = it.toInt()
if (size in MIN_VIEW_SIZE..MAX_VIEW_SIZE && size <= w / 2 && size <= h / 2) {
viewWidth = size
viewHeight = size
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// untouchable
viewUntouchable = FFI.getLocalOption("floating-window-untouchable") == "Y"
// transparency
FFI.getLocalOption("floating-window-transparency").let {
if (it.isNotEmpty()) {
try {
val transparency = it.toInt()
if (transparency in 0..10) {
viewTransparency = transparency * 1f / 10
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// custom svg
FFI.getLocalOption("floating-window-svg").let {
if (it.isNotEmpty()) {
customSvg = it
}
}
// position
lastLayoutX = 0
lastLayoutY = (wh.second - viewHeight) / 2
lastOrientation = resources.configuration.orientation
}
private fun performClick() {
showPopupMenu()
}
override fun onTouch(view: View?, event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
dragging = false
lastDownX = event.rawX
lastDownY = event.rawY
}
MotionEvent.ACTION_UP -> {
val clickDragTolerance = 10f
if (abs(event.rawX - lastDownX) < clickDragTolerance && abs(event.rawY - lastDownY) < clickDragTolerance) {
performClick()
} else {
moveToScreenSide()
}
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - lastDownX
val dy = event.rawY - lastDownY
// ignore too small fist start moving(some time is click)
if (!dragging && dx*dx+dy*dy < 25) {
return false
}
dragging = true
layoutParams.x = event.rawX.toInt()
layoutParams.y = event.rawY.toInt()
layoutParams.width = viewWidth
floatingView.setImageDrawable(originalDrawable)
windowManager.updateViewLayout(view, layoutParams)
lastLayoutX = layoutParams.x
lastLayoutY = layoutParams.y
}
}
return false
}
private fun moveToScreenSide(center: Boolean = false) {
val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
val wh = getScreenSize(windowManager)
val w = wh.first
if (layoutParams.x < w / 2) {
layoutParams.x = 0
floatingView.setImageDrawable(rightHalfDrawable)
} else {
layoutParams.x = w - viewWidth / 2
floatingView.setImageDrawable(leftHalfDrawable)
}
if (center) {
layoutParams.y = (wh.second - viewHeight) / 2
}
layoutParams.width = viewWidth / 2
windowManager.updateViewLayout(floatingView, layoutParams)
lastLayoutX = layoutParams.x
lastLayoutY = layoutParams.y
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (newConfig.orientation != lastOrientation) {
lastOrientation = newConfig.orientation
val wh = getScreenSize(windowManager)
Log.d(logTag, "orientation: $lastOrientation, screen size: ${wh.first} x ${wh.second}")
val newW = wh.first
val newH = wh.second
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE || newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
// Proportional change
layoutParams.x = (layoutParams.x.toFloat() / newH.toFloat() * newW.toFloat()).toInt()
layoutParams.y = (layoutParams.y.toFloat() / newW.toFloat() * newH.toFloat()).toInt()
}
moveToScreenSide()
}
}
private fun showPopupMenu() {
val popupMenu = PopupMenu(this, floatingView)
val idShowRustDesk = 0
popupMenu.menu.add(0, idShowRustDesk, 0, translate("Show RustDesk"))
val idStopService = 1
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
idShowRustDesk -> {
openMainActivity()
true
}
idStopService -> {
stopMainService()
true
}
else -> false
}
}
popupMenu.setOnDismissListener {
moveToScreenSide()
}
popupMenu.show()
}
private fun openMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT
)
try {
pendingIntent.send()
} catch (e: PendingIntent.CanceledException) {
e.printStackTrace()
}
}
private fun stopMainService() {
MainActivity.flutterMethodChannel?.invokeMethod("stop_service", null)
}
enum class KeepScreenOn {
NEVER,
DURING_CONTROLLED,
SERVICE_ON,
}
private val handler = Handler(Looper.getMainLooper())
private val runnable = object : Runnable {
override fun run() {
if (updateKeepScreenOnLayoutParams()) {
windowManager.updateViewLayout(floatingView, layoutParams)
}
handler.postDelayed(this, 1000) // 1000 milliseconds = 1 second
}
}
private fun updateKeepScreenOnLayoutParams(): Boolean {
val oldOn = layoutParams.flags and FLAG_KEEP_SCREEN_ON != 0
val newOn = keepScreenOn == KeepScreenOn.SERVICE_ON || (keepScreenOn == KeepScreenOn.DURING_CONTROLLED && MainService.isStart)
if (oldOn != newOn) {
Log.d(logTag, "change keep screen on to $newOn")
if (newOn) {
layoutParams.flags = layoutParams.flags or FLAG_KEEP_SCREEN_ON
} else {
layoutParams.flags = layoutParams.flags and FLAG_KEEP_SCREEN_ON.inv()
}
return true
}
return false
}
}

View File

@@ -18,7 +18,9 @@ import android.widget.EditText
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityNodeInfo
import android.view.KeyEvent as KeyEventAndroid
import android.graphics.Rect import android.graphics.Rect
import android.media.AudioManager
import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.AccessibilityServiceInfo
import android.accessibilityservice.AccessibilityServiceInfo.FLAG_INPUT_METHOD_EDITOR import android.accessibilityservice.AccessibilityServiceInfo.FLAG_INPUT_METHOD_EDITOR
import android.accessibilityservice.AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS import android.accessibilityservice.AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
@@ -75,6 +77,8 @@ class InputService : AccessibilityService() {
private var fakeEditTextForTextStateCalculation: EditText? = null private var fakeEditTextForTextStateCalculation: EditText? = null
private val volumeController: VolumeController by lazy { VolumeController(applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager) }
@RequiresApi(Build.VERSION_CODES.N) @RequiresApi(Build.VERSION_CODES.N)
fun onMouseInput(mask: Int, _x: Int, _y: Int) { fun onMouseInput(mask: Int, _x: Int, _y: Int) {
val x = max(0, _x) val x = max(0, _x)
@@ -294,6 +298,18 @@ class InputService : AccessibilityService() {
Log.d(logTag, "onKeyEvent $keyEvent textToCommit:$textToCommit") Log.d(logTag, "onKeyEvent $keyEvent textToCommit:$textToCommit")
var ke: KeyEventAndroid? = null
if (Build.VERSION.SDK_INT < 33 || textToCommit == null) {
ke = KeyEventConverter.toAndroidKeyEvent(keyEvent)
}
ke?.let { event ->
if (tryHandleVolumeKeyEvent(event)) {
return
} else if (tryHandlePowerKeyEvent(event)) {
return
}
}
if (Build.VERSION.SDK_INT >= 33) { if (Build.VERSION.SDK_INT >= 33) {
getInputMethod()?.let { inputMethod -> getInputMethod()?.let { inputMethod ->
inputMethod.getCurrentInputConnection()?.let { inputConnection -> inputMethod.getCurrentInputConnection()?.let { inputConnection ->
@@ -302,7 +318,7 @@ class InputService : AccessibilityService() {
inputConnection.commitText(text, 1, null) inputConnection.commitText(text, 1, null)
} }
} else { } else {
KeyEventConverter.toAndroidKeyEvent(keyEvent).let { event -> ke?.let { event ->
inputConnection.sendKeyEvent(event) inputConnection.sendKeyEvent(event)
} }
} }
@@ -311,7 +327,7 @@ class InputService : AccessibilityService() {
} else { } else {
val handler = Handler(Looper.getMainLooper()) val handler = Handler(Looper.getMainLooper())
handler.post { handler.post {
KeyEventConverter.toAndroidKeyEvent(keyEvent)?.let { event -> ke?.let { event ->
val possibleNodes = possibleAccessibiltyNodes() val possibleNodes = possibleAccessibiltyNodes()
Log.d(logTag, "possibleNodes:$possibleNodes") Log.d(logTag, "possibleNodes:$possibleNodes")
for (item in possibleNodes) { for (item in possibleNodes) {
@@ -325,6 +341,43 @@ class InputService : AccessibilityService() {
} }
} }
private fun tryHandleVolumeKeyEvent(event: KeyEventAndroid): Boolean {
when (event.keyCode) {
KeyEventAndroid.KEYCODE_VOLUME_UP -> {
if (event.action == KeyEventAndroid.ACTION_DOWN) {
volumeController.raiseVolume(null, true, AudioManager.STREAM_SYSTEM)
}
return true
}
KeyEventAndroid.KEYCODE_VOLUME_DOWN -> {
if (event.action == KeyEventAndroid.ACTION_DOWN) {
volumeController.lowerVolume(null, true, AudioManager.STREAM_SYSTEM)
}
return true
}
KeyEventAndroid.KEYCODE_VOLUME_MUTE -> {
if (event.action == KeyEventAndroid.ACTION_DOWN) {
volumeController.toggleMute(true, AudioManager.STREAM_SYSTEM)
}
return true
}
else -> {
return false
}
}
}
private fun tryHandlePowerKeyEvent(event: KeyEventAndroid): Boolean {
if (event.keyCode == KeyEventAndroid.KEYCODE_POWER) {
// Perform power dialog action when action is up
if (event.action == KeyEventAndroid.ACTION_UP) {
performGlobalAction(GLOBAL_ACTION_POWER_DIALOG);
}
return true
}
return false
}
private fun insertAccessibilityNode(list: LinkedList<AccessibilityNodeInfo>, node: AccessibilityNodeInfo) { private fun insertAccessibilityNode(list: LinkedList<AccessibilityNodeInfo>, node: AccessibilityNodeInfo) {
if (node == null) { if (node == null) {
return return
@@ -422,7 +475,7 @@ class InputService : AccessibilityService() {
return linkedList return linkedList
} }
private fun trySendKeyEvent(event: android.view.KeyEvent, node: AccessibilityNodeInfo, textToCommit: String?): Boolean { private fun trySendKeyEvent(event: KeyEventAndroid, node: AccessibilityNodeInfo, textToCommit: String?): Boolean {
node.refresh() node.refresh()
this.fakeEditTextForTextStateCalculation?.setSelection(0,0) this.fakeEditTextForTextStateCalculation?.setSelection(0,0)
this.fakeEditTextForTextStateCalculation?.setText(null) this.fakeEditTextForTextStateCalculation?.setText(null)
@@ -487,10 +540,10 @@ class InputService : AccessibilityService() {
it.layout(rect.left, rect.top, rect.right, rect.bottom) it.layout(rect.left, rect.top, rect.right, rect.bottom)
it.onPreDraw() it.onPreDraw()
if (event.action == android.view.KeyEvent.ACTION_DOWN) { if (event.action == KeyEventAndroid.ACTION_DOWN) {
val succ = it.onKeyDown(event.getKeyCode(), event) val succ = it.onKeyDown(event.getKeyCode(), event)
Log.d(logTag, "onKeyDown $succ") Log.d(logTag, "onKeyDown $succ")
} else if (event.action == android.view.KeyEvent.ACTION_UP) { } else if (event.action == KeyEventAndroid.ACTION_UP) {
val success = it.onKeyUp(event.getKeyCode(), event) val success = it.onKeyUp(event.getKeyCode(), event)
Log.d(logTag, "keyup $success") Log.d(logTag, "keyup $success")
} else {} } else {}

View File

@@ -37,6 +37,8 @@ object KeyEventConverter {
action = KeyEvent.ACTION_UP action = KeyEvent.ACTION_UP
} }
// FIXME: The last parameter is the repeat count, not modifiers ?
// https://developer.android.com/reference/android/view/KeyEvent#KeyEvent(long,%20long,%20int,%20int,%20int)
return KeyEvent(0, 0, action, chrValue, 0, modifiers) return KeyEvent(0, 0, action, chrValue, 0, modifiers)
} }
@@ -112,6 +114,10 @@ object KeyEventConverter {
ControlKey.Delete -> KeyEvent.KEYCODE_FORWARD_DEL ControlKey.Delete -> KeyEvent.KEYCODE_FORWARD_DEL
ControlKey.Clear -> KeyEvent.KEYCODE_CLEAR ControlKey.Clear -> KeyEvent.KEYCODE_CLEAR
ControlKey.Pause -> KeyEvent.KEYCODE_BREAK ControlKey.Pause -> KeyEvent.KEYCODE_BREAK
ControlKey.VolumeMute -> KeyEvent.KEYCODE_VOLUME_MUTE
ControlKey.VolumeUp -> KeyEvent.KEYCODE_VOLUME_UP
ControlKey.VolumeDown -> KeyEvent.KEYCODE_VOLUME_DOWN
ControlKey.Power -> KeyEvent.KEYCODE_POWER
else -> 0 // Default to unknown. else -> 0 // Default to unknown.
} }
} }

View File

@@ -233,6 +233,17 @@ class MainActivity : FlutterActivity() {
result.success(false) result.success(false)
} }
} }
GET_VALUE -> {
if (call.arguments is String) {
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
result.success(isSupportVoiceCall())
} else {
result.error("-1", "No such key", null)
}
} else {
result.success(null)
}
}
"on_voice_call_started" -> { "on_voice_call_started" -> {
onVoiceCallStarted() onVoiceCallStarted()
} }
@@ -252,19 +263,9 @@ class MainActivity : FlutterActivity() {
val codecArray = JSONArray() val codecArray = JSONArray()
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
var w = 0 val wh = getScreenSize(windowManager)
var h = 0 var w = wh.first
@Suppress("DEPRECATION") var h = wh.second
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val m = windowManager.maximumWindowMetrics
w = m.bounds.width()
h = m.bounds.height()
} else {
val dm = DisplayMetrics()
windowManager.defaultDisplay.getRealMetrics(dm)
w = dm.widthPixels
h = dm.heightPixels
}
val align = 64 val align = 64
w = (w + align - 1) / align * align w = (w + align - 1) / align * align
h = (h + align - 1) / align * align h = (h + align - 1) / align * align
@@ -374,4 +375,17 @@ class MainActivity : FlutterActivity() {
Log.d(logTag, "onVoiceCallClosed success") Log.d(logTag, "onVoiceCallClosed success")
} }
} }
override fun onStop() {
super.onStop()
val disableFloatingWindow = FFI.getLocalOption("disable-floating-window") == "Y"
if (!disableFloatingWindow && MainService.isReady) {
startService(Intent(this, FloatingWindowService::class.java))
}
}
override fun onStart() {
super.onStart()
stopService(Intent(this, FloatingWindowService::class.java))
}
} }

View File

@@ -64,9 +64,9 @@ class MainService : Service() {
@Keep @Keep
@RequiresApi(Build.VERSION_CODES.N) @RequiresApi(Build.VERSION_CODES.N)
fun rustPointerInput(kind: String, mask: Int, x: Int, y: Int) { fun rustPointerInput(kind: Int, mask: Int, x: Int, y: Int) {
// turn on screen with LIFT_DOWN when screen off // turn on screen with LIFT_DOWN when screen off
if (!powerManager.isInteractive && (kind == "touch" || mask == LIFT_DOWN)) { if (!powerManager.isInteractive && (kind == 0 || mask == LIFT_DOWN)) {
if (wakeLock.isHeld) { if (wakeLock.isHeld) {
Log.d(logTag, "Turn on Screen, WakeLock release") Log.d(logTag, "Turn on Screen, WakeLock release")
wakeLock.release() wakeLock.release()
@@ -75,10 +75,10 @@ class MainService : Service() {
wakeLock.acquire(5000) wakeLock.acquire(5000)
} else { } else {
when (kind) { when (kind) {
"touch" -> { 0 -> { // touch
InputService.ctx?.onTouchInput(mask, x, y) InputService.ctx?.onTouchInput(mask, x, y)
} }
"mouse" -> { 1 -> { // mouse
InputService.ctx?.onMouseInput(mask, x, y) InputService.ctx?.onMouseInput(mask, x, y)
} }
else -> { else -> {
@@ -103,6 +103,9 @@ class MainService : Service() {
put("scale",SCREEN_INFO.scale) put("scale",SCREEN_INFO.scale)
}.toString() }.toString()
} }
"is_start" -> {
isStart.toString()
}
else -> "" else -> ""
} }
} }
@@ -172,10 +175,10 @@ class MainService : Service() {
Log.d(logTag, "from rust:stop_capture") Log.d(logTag, "from rust:stop_capture")
stopCapture() stopCapture()
} }
"is_hardware_codec" -> { "half_scale" -> {
val isHwCodec = arg1.toBoolean() val halfScale = arg1.toBoolean()
if (isHardwareCodec != isHwCodec) { if (isHalfScale != halfScale) {
isHardwareCodec = isHwCodec isHalfScale = halfScale
updateScreenInfo(resources.configuration.orientation) updateScreenInfo(resources.configuration.orientation)
} }
@@ -191,11 +194,6 @@ class MainService : Service() {
private val powerManager: PowerManager by lazy { applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager } private val powerManager: PowerManager by lazy { applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager }
private val wakeLock: PowerManager.WakeLock by lazy { powerManager.newWakeLock(PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.SCREEN_BRIGHT_WAKE_LOCK, "rustdesk:wakelock")} private val wakeLock: PowerManager.WakeLock by lazy { powerManager.newWakeLock(PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.SCREEN_BRIGHT_WAKE_LOCK, "rustdesk:wakelock")}
private fun translate(input: String): String {
Log.d(logTag, "translate:$LOCAL_NAME")
return FFI.translateLocale(LOCAL_NAME, input)
}
companion object { companion object {
private var _isReady = false // media permission ready status private var _isReady = false // media permission ready status
private var _isStart = false // screen capture start status private var _isStart = false // screen capture start status
@@ -252,10 +250,11 @@ class MainService : Service() {
override fun onDestroy() { override fun onDestroy() {
checkMediaPermission() checkMediaPermission()
stopService(Intent(this, FloatingWindowService::class.java))
super.onDestroy() super.onDestroy()
} }
private var isHardwareCodec: Boolean? = null; private var isHalfScale: Boolean? = null;
private fun updateScreenInfo(orientation: Int) { private fun updateScreenInfo(orientation: Int) {
var w: Int var w: Int
var h: Int var h: Int
@@ -288,7 +287,7 @@ class MainService : Service() {
Log.d(logTag,"updateScreenInfo:w:$w,h:$h") Log.d(logTag,"updateScreenInfo:w:$w,h:$h")
var scale = 1 var scale = 1
if (w != 0 && h != 0) { if (w != 0 && h != 0) {
if (isHardwareCodec == false && (w > MAX_SCREEN_SIZE || h > MAX_SCREEN_SIZE)) { if (isHalfScale == true && (w > MAX_SCREEN_SIZE || h > MAX_SCREEN_SIZE)) {
scale = 2 scale = 2
w /= scale w /= scale
h /= scale h /= scale
@@ -486,6 +485,7 @@ class MainService : Service() {
mediaProjection = null mediaProjection = null
checkMediaPermission() checkMediaPermission()
stopForeground(true) stopForeground(true)
stopService(Intent(this, FloatingWindowService::class.java))
stopSelf() stopSelf()
} }

View File

@@ -0,0 +1,78 @@
package com.carriez.flutter_hbb
// Inspired by https://github.com/yosemiteyss/flutter_volume_controller/blob/main/android/src/main/kotlin/com/yosemiteyss/flutter_volume_controller/VolumeController.kt
import android.media.AudioManager
import android.os.Build
import android.util.Log
class VolumeController(private val audioManager: AudioManager) {
private val logTag = "volume controller"
fun getVolume(streamType: Int): Double {
val current = audioManager.getStreamVolume(streamType)
val max = audioManager.getStreamMaxVolume(streamType)
return current.toDouble() / max
}
fun setVolume(volume: Double, showSystemUI: Boolean, streamType: Int) {
val max = audioManager.getStreamMaxVolume(streamType)
audioManager.setStreamVolume(
streamType,
(max * volume).toInt(),
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
}
fun raiseVolume(step: Double?, showSystemUI: Boolean, streamType: Int) {
if (step == null) {
audioManager.adjustStreamVolume(
streamType,
AudioManager.ADJUST_RAISE,
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
} else {
val target = getVolume(streamType) + step
setVolume(target, showSystemUI, streamType)
}
}
fun lowerVolume(step: Double?, showSystemUI: Boolean, streamType: Int) {
if (step == null) {
audioManager.adjustStreamVolume(
streamType,
AudioManager.ADJUST_LOWER,
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
} else {
val target = getVolume(streamType) - step
setVolume(target, showSystemUI, streamType)
}
}
fun getMute(streamType: Int): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
audioManager.isStreamMute(streamType)
} else {
audioManager.getStreamVolume(streamType) == 0
}
}
private fun setMute(isMuted: Boolean, showSystemUI: Boolean, streamType: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
audioManager.adjustStreamVolume(
streamType,
if (isMuted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
} else {
audioManager.setStreamMute(streamType, isMuted)
}
}
fun toggleMute(showSystemUI: Boolean, streamType: Int) {
val isMuted = getMute(streamType)
setMute(!isMuted, showSystemUI, streamType)
}
}

View File

@@ -15,10 +15,14 @@ import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.provider.Settings.* import android.provider.Settings.*
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat.getSystemService import androidx.core.content.ContextCompat.getSystemService
import com.hjq.permissions.Permission import com.hjq.permissions.Permission
import com.hjq.permissions.XXPermissions import com.hjq.permissions.XXPermissions
import ffi.FFI
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.* import java.util.*
@@ -43,6 +47,9 @@ const val START_ACTION = "start_action"
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt" const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt" const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir" const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
const val GET_VALUE = "get_value"
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
const val KEY_SHARED_PREFERENCES = "KEY_SHARED_PREFERENCES" const val KEY_SHARED_PREFERENCES = "KEY_SHARED_PREFERENCES"
const val KEY_START_ON_BOOT_OPT = "KEY_START_ON_BOOT_OPT" const val KEY_START_ON_BOOT_OPT = "KEY_START_ON_BOOT_OPT"
@@ -56,6 +63,11 @@ data class Info(
var width: Int, var height: Int, var scale: Int, var dpi: Int var width: Int, var height: Int, var scale: Int, var dpi: Int
) )
fun isSupportVoiceCall(): Boolean {
// https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_COMMUNICATION
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
}
fun requestPermission(context: Context, type: String) { fun requestPermission(context: Context, type: String) {
XXPermissions.with(context) XXPermissions.with(context)
.permission(type) .permission(type)
@@ -120,3 +132,26 @@ class AudioReader(val bufSize: Int, private val maxFrames: Int) {
} }
} }
} }
fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
var w = 0
var h = 0
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val m = windowManager.maximumWindowMetrics
w = m.bounds.width()
h = m.bounds.height()
} else {
val dm = DisplayMetrics()
windowManager.defaultDisplay.getRealMetrics(dm)
w = dm.widthPixels
h = dm.heightPixels
}
return Pair(w, h)
}
fun translate(input: String): String {
Log.d("common", "translate:$LOCAL_NAME")
return FFI.translateLocale(LOCAL_NAME, input)
}

View File

@@ -19,4 +19,5 @@ object FFI {
external fun refreshScreen() external fun refreshScreen()
external fun setFrameRawEnable(name: String, value: Boolean) external fun setFrameRawEnable(name: String, value: Boolean)
external fun setCodecInfo(info: String) external fun setCodecInfo(info: String)
external fun getLocalOption(key: String): String
} }

View File

@@ -0,0 +1,7 @@
<vector xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android" android:height="320dp" android:viewportHeight="32" android:viewportWidth="32" android:width="320dp">
<path android:fillColor="#ffffff" android:pathData="M16,0L16,0A16,16 0,0 1,32 16L32,16A16,16 0,0 1,16 32L16,32A16,16 0,0 1,0 16L0,16A16,16 0,0 1,16 0z" android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#1a1a1a" android:pathData="m23.89,10.135 l-1.807,1.795c-0.318,0.285 -0.472,0.744 -0.293,1.131 1.204,2.518 0.747,5.52 -1.228,7.494 -1.976,1.973 -4.981,2.429 -7.502,1.226 -0.371,-0.166 -0.807,-0.025 -1.093,0.265l-1.836,1.833c-0.216,0.211 -0.322,0.51 -0.288,0.809 0.034,0.3 0.206,0.567 0.463,0.723 4.326,2.618 9.882,1.951 13.463,-1.618 3.581,-3.568 4.264,-9.115 1.655,-13.443 -0.15,-0.263 -0.414,-0.442 -0.714,-0.484 -0.3,-0.043 -0.603,0.058 -0.819,0.269zM8.265,8.184c-3.599,3.554 -4.304,9.103 -1.709,13.441 0.15,0.264 0.413,0.443 0.714,0.485 0.3,0.042 0.603,-0.058 0.82,-0.27l1.797,-1.785c0.325,-0.285 0.484,-0.749 0.303,-1.141 -1.204,-2.518 -0.748,-5.52 1.228,-7.493 1.975,-1.973 4.981,-2.429 7.502,-1.227 0.367,0.165 0.797,0.028 1.084,-0.254l1.846,-1.844c0.216,-0.211 0.322,-0.509 0.288,-0.809 -0.035,-0.299 -0.206,-0.566 -0.463,-0.723 -4.334,-2.596 -9.881,-1.908 -13.448,1.668z" android:strokeWidth="0.987992"/>
</vector>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="-4 -4 32 32" width="24px" fill="#5f6368"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>

After

Width:  |  Height:  |  Size: 277 B

564
flutter/build_fdroid.sh Executable file
View File

@@ -0,0 +1,564 @@
#!/bin/bash
set -x
#
# Script to build F-Droid release of RustDesk
#
# Copyright (C) 2024, The RustDesk Authors
# 2024, Vasyl Gello <vasek.gello@gmail.com>
#
# The script is invoked by F-Droid builder system ste-by-step.
#
# It accepts the following arguments:
#
# - versionName from https://github.com/rustdesk/rustdesk/releases/download/fdroid-version/rustdesk-version.txt
# - versionCode from https://github.com/rustdesk/rustdesk/releases/download/fdroid-version/rustdesk-version.txt
# - Android architecture to build APK for: armeabi-v7a arm64-v8av x86 x86_64
# - The build step to execute:
#
# + sudo-deps: as root, install needed Debian packages into builder VM
# + prebuild: patch sources and do other stuff before the build
# + build: perform actual build of APK file
#
# Parse command-line arguments
VERNAME="${1}"
VERCODE="${2}"
ANDROID_ABI="${3}"
BUILDSTEP="${4}"
if [ -z "${VERNAME}" ] || [ -z "${VERCODE}" ] || [ -z "${ANDROID_ABI}" ] ||
[ -z "${BUILDSTEP}" ]; then
echo "ERROR: Command-line arguments are all required to be non-empty!" >&2
exit 1
fi
# Set various architecture-specific identifiers
case "${ANDROID_ABI}" in
arm64-v8a)
FLUTTER_TARGET=android-arm64
NDK_TARGET=aarch64-linux-android
RUST_TARGET=aarch64-linux-android
RUSTDESK_FEATURES='flutter,hwcodec'
;;
armeabi-v7a)
FLUTTER_TARGET=android-arm
NDK_TARGET=arm-linux-androideabi
RUST_TARGET=armv7-linux-androideabi
RUSTDESK_FEATURES='flutter,hwcodec'
;;
x86_64)
FLUTTER_TARGET=android-x64
NDK_TARGET=x86_64-linux-android
RUST_TARGET=x86_64-linux-android
RUSTDESK_FEATURES='flutter'
;;
x86)
FLUTTER_TARGET=android-x86
NDK_TARGET=i686-linux-android
RUST_TARGET=i686-linux-android
RUSTDESK_FEATURES='flutter'
;;
*)
echo "ERROR: Unknown Android ABI '${ANDROID_ABI}'!" >&2
exit 1
;;
esac
# Check ANDROID_SDK_ROOT and sdkmanager present on PATH
if [ ! -d "${ANDROID_SDK_ROOT}" ] || ! command -v sdkmanager 1>/dev/null; then
echo "ERROR: Can not find Android SDK!" >&2
exit 1
fi
# Export necessary variables
export PATH="${PATH}:${HOME}/flutter/bin:${HOME}/depot_tools"
export VCPKG_ROOT="${HOME}/vcpkg"
# Now act depending on build step
# NOTE: F-Droid maintainers require explicit declaration of dependencies
# as root via `Builds.sudo` F-Droid metadata directive:
# https://gitlab.com/fdroid/fdroiddata/-/merge_requests/15343#note_1988918695
case "${BUILDSTEP}" in
prebuild)
# prebuild: patch sources and do other stuff before the build
#
# Extract required versions for NDK, Rust, Flutter from
# '.github/workflows/flutter-build.yml'
#
CARGO_NDK_VERSION="$(yq -r \
.env.CARGO_NDK_VERSION \
.github/workflows/flutter-build.yml)"
FLUTTER_VERSION="$(yq -r \
.env.ANDROID_FLUTTER_VERSION \
.github/workflows/flutter-build.yml)"
if [ -z "${FLUTTER_VERSION}" ]; then
FLUTTER_VERSION="$(yq -r \
.env.FLUTTER_VERSION \
.github/workflows/flutter-build.yml)"
fi
FLUTTER_RUST_BRIDGE_VERSION="$(yq -r \
.env.FLUTTER_RUST_BRIDGE_VERSION \
.github/workflows/flutter-build.yml)"
NDK_VERSION="$(yq -r \
.env.NDK_VERSION \
.github/workflows/flutter-build.yml)"
RUST_VERSION="$(yq -r \
.env.RUST_VERSION \
.github/workflows/flutter-build.yml)"
VCPKG_COMMIT_ID="$(yq -r \
.env.VCPKG_COMMIT_ID \
.github/workflows/flutter-build.yml)"
if [ -z "${CARGO_NDK_VERSION}" ] || [ -z "${FLUTTER_VERSION}" ] ||
[ -z "${FLUTTER_RUST_BRIDGE_VERSION}" ] ||
[ -z "${NDK_VERSION}" ] || [ -z "${RUST_VERSION}" ] ||
[ -z "${VCPKG_COMMIT_ID}" ]; then
echo "ERROR: Can not identify all required versions!" >&2
exit 1
fi
# Map NDK version to revision
NDK_VERSION="$(wget \
-qO- \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
'https://api.github.com/repos/android/ndk/releases' |
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
if [ -z "${NDK_VERSION}" ]; then
echo "ERROR: Can not map Android NDK codename to revision!" >&2
exit 1
fi
export ANDROID_NDK_HOME="${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}"
export ANDROID_NDK_ROOT="${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}"
#
# Install the components
#
set -e
# Install Android NDK
if [ ! -d "${ANDROID_NDK_ROOT}" ]; then
sdkmanager --install "ndk;${NDK_VERSION}"
fi
# Install Flutter
if [ ! -f "${HOME}/flutter/bin/flutter" ]; then
pushd "${HOME}"
git clone https://github.com/flutter/flutter
pushd flutter
git reset --hard "${FLUTTER_VERSION}"
flutter config --no-analytics
popd # flutter
popd # ${HOME}
fi
# Install Rust
if [ ! -f "${HOME}/rustup/rustup-init.sh" ]; then
pushd "${HOME}"
git clone --depth 1 https://github.com/rust-lang/rustup
popd # ${HOME}
fi
pushd "${HOME}/rustup"
bash rustup-init.sh -y \
--target "${RUST_TARGET}" \
--default-toolchain "${RUST_VERSION}"
popd
if ! command -v cargo 1>/dev/null 2>&1; then
. "${HOME}/.cargo/env"
fi
# Install cargo-ndk
cargo install \
cargo-ndk \
--version "${CARGO_NDK_VERSION}"
# Install rust bridge generator
cargo install cargo-expand
cargo install flutter_rust_bridge_codegen \
--version "${FLUTTER_RUST_BRIDGE_VERSION}" \
--features "uuid"
# Populate native vcpkg dependencies
if [ ! -d "${VCPKG_ROOT}" ]; then
pushd "${HOME}"
git clone \
https://github.com/Microsoft/vcpkg.git
git clone \
https://github.com/Microsoft/vcpkg-tool.git
pushd vcpkg-tool
mkdir build
pushd build
cmake \
-DCMAKE_BUILD_TYPE=Release \
-G 'Ninja' \
-DVCPKG_DEVELOPMENT_WARNINGS=OFF \
..
cmake --build .
popd # build
popd # vcpkg-tool
pushd vcpkg
git reset --hard "${VCPKG_COMMIT_ID}"
cp -a ../vcpkg-tool/build/vcpkg vcpkg
# disable telemetry
touch "vcpkg.disable-metrics"
popd # vcpkg
popd # ${HOME}
fi
# Install depot-tools for x86
if [ "${ANDROID_ABI}" = "x86" ]; then
if [ ! -d "${HOME}/depot_tools" ]; then
pushd "${HOME}"
git clone \
--depth 1 \
https://chromium.googlesource.com/chromium/tools/depot_tools.git
popd # ${HOME}
fi
fi
# Patch the RustDesk sources
git apply res/fdroid/patches/*.patch
sed \
-i \
-e '/gms/d' \
flutter/android/build.gradle \
flutter/android/app/build.gradle
sed \
-i \
-e '/firebase_analytics/d' \
flutter/pubspec.yaml
sed \
-i \
-e '/ firebase/,/ version/d' \
flutter/pubspec.lock
sed \
-i \
-e '/firebase/Id' \
flutter/lib/main.dart
if [ "${FLUTTER_VERSION}" = "3.13.9" ]; then
# Fix for android 3.13.9
# https://github.com/rustdesk/rustdesk/blob/285e974d1a52c891d5fcc28e963d724e085558bc/.github/workflows/flutter-build.yml#L862
sed \
-i \
-e 's/extended_text: .*/extended_text: 11.1.0/' \
-e 's/uni_links_desktop/#uni_links_desktop/g' \
flutter/pubspec.yaml
set --
while read -r _1; do
set -- "$@" "${_1}"
done 0<<.a
$(find flutter/lib/ -type f -name "*dart*")
.a
sed \
-i \
-e 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g' \
"$@"
set --
fi
sed -i "s/FLUTTER_VERSION_PLACEHOLDER/${FLUTTER_VERSION}/" flutter-sdk/.gclient
;;
build)
# build: perform actual build of APK file
set -e
#
# Extract required versions for NDK, Rust, Flutter from
# '.github/workflows/flutter-build.yml'
#
FLUTTER_VERSION="$(yq -r \
.env.ANDROID_FLUTTER_VERSION \
.github/workflows/flutter-build.yml)"
if [ -z "${FLUTTER_VERSION}" ]; then
FLUTTER_VERSION="$(yq -r \
.env.FLUTTER_VERSION \
.github/workflows/flutter-build.yml)"
fi
NDK_VERSION="$(yq -r \
.env.NDK_VERSION \
.github/workflows/flutter-build.yml)"
# Map NDK version to revision
NDK_VERSION="$(wget \
-qO- \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
'https://api.github.com/repos/android/ndk/releases' |
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
if [ -z "${NDK_VERSION}" ]; then
echo "ERROR: Can not map Android NDK codename to revision!" >&2
exit 1
fi
export ANDROID_NDK_HOME="${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}"
export ANDROID_NDK_ROOT="${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}"
if ! command -v cargo 1>/dev/null 2>&1; then
. "${HOME}/.cargo/env"
fi
# Download Flutter dependencies
pushd flutter
flutter packages pub get
popd # flutter
# Generate FFI bindings
flutter_rust_bridge_codegen \
--rust-input ./src/flutter_ffi.rs \
--dart-output ./flutter/lib/generated_bridge.dart
# Build host android deps
bash flutter/build_android_deps.sh "${ANDROID_ABI}"
# Build rustdesk lib
cargo ndk \
--platform 21 \
--target "${RUST_TARGET}" \
--bindgen \
build \
--release \
--features "${RUSTDESK_FEATURES}"
mkdir -p "flutter/android/app/src/main/jniLibs/${ANDROID_ABI}"
cp "target/${RUST_TARGET}/release/liblibrustdesk.so" \
"flutter/android/app/src/main/jniLibs/${ANDROID_ABI}/librustdesk.so"
cp "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/${NDK_TARGET}/libc++_shared.so" \
"flutter/android/app/src/main/jniLibs/${ANDROID_ABI}/"
"${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" \
"flutter/android/app/src/main/jniLibs/${ANDROID_ABI}"/*
# Build flutter-jit-release for x86
if [ "${ANDROID_ABI}" = "x86" ]; then
pushd flutter-sdk
echo "## Sync flutter engine sources"
echo "### We need fakeroot because chromium base image is unpacked with weird uid/gid ownership"
sed -i "s/FLUTTER_VERSION_PLACEHOLDER/${FLUTTER_VERSION}/" .gclient
export FAKEROOTDONTTRYCHOWN=1
fakeroot gclient sync
unset FAKEROOTDONTTRYCHOWN
pushd src
echo "## Patch away Google Play dependencies"
rm \
flutter/shell/platform/android/io/flutter/app/FlutterPlayStoreSplitApplication.java \
flutter/shell/platform/android/io/flutter/embedding/engine/deferredcomponents/PlayStoreDeferredComponentManager.java flutter/shell/platform/android/io/flutter/embedding/android/FlutterPlayStoreSplitApplication.java
sed \
-i \
-e '/PlayStore/d' \
flutter/tools/android_lint/project.xml \
flutter/shell/platform/android/BUILD.gn
sed \
-i \
-e '/com.google.android.play/d' \
flutter/tools/androidx/files.json
echo "## Configure android engine build"
flutter/tools/gn \
--android --android-cpu x86 --runtime-mode=jit_release \
--no-goma --no-enable-unittests
echo "## Perform android engine build"
ninja -C out/android_jit_release_x86
echo "## Configure host engine build"
flutter/tools/gn \
--android-cpu x86 --runtime-mode=jit_release \
--no-goma --no-enable-unittests
echo "## Perform android engine build"
ninja -C out/host_jit_release_x86
echo "## Rename host engine"
mv out/host_jit_release_x86 out/host_jit_release
echo "## Mimic jit_release engine to debug to use with flutter build apk"
pushd out/android_jit_release_x86
sed \
-e 's/jit_release/debug/' \
flutter_embedding_jit_release.maven-metadata.xml \
1>flutter_embedding_debug.maven-metadata.xml
sed \
-e 's/jit_release/debug/' \
flutter_embedding_jit_release.pom \
1>flutter_embedding_debug.pom
sed \
-e 's/jit_release/debug/' \
x86_jit_release.maven-metadata.xml \
1>x86_debug.maven-metadata.xml
sed \
-e 's/jit_release/debug/' \
x86_jit_release.pom \
1>x86_debug.pom
cp -a \
flutter_embedding_jit_release-sources.jar \
flutter_embedding_debug-sources.jar
cp -a \
flutter_embedding_jit_release.jar \
flutter_embedding_debug.jar
cp -a \
x86_jit_release.jar \
x86_debug.jar
popd # out/android_jit_release_x86
popd # src
popd # flutter-sdk
echo "# Clean up intermediate engine files and show free space"
rm -rf \
flutter-sdk/src/out/android_jit_release_x86/obj \
flutter-sdk/src/out/host_jit_release/obj
mv flutter-sdk/src/out flutter-out
rm -rf flutter-sdk
mkdir -p flutter-sdk/src/
mv flutter-out flutter-sdk/src/out
fi
# Build the apk
pushd flutter
if [ "${ANDROID_ABI}" = "x86" ]; then
flutter build apk \
--local-engine-src-path="$(readlink -mf "../flutter-sdk/src")" \
--local-engine=android_jit_release_x86 \
--debug \
--build-number="${VERCODE}" \
--build-name="${VERNAME}" \
--target-platform "${FLUTTER_TARGET}"
else
flutter build apk \
--release \
--build-number="${VERCODE}" \
--build-name="${VERNAME}" \
--target-platform "${FLUTTER_TARGET}"
fi
popd # flutter
rm -rf flutter-sdk
# Special step for fdroiddata CI builds to remove .gitconfig
rm -f /home/vagrant/.gitconfig
;;
*)
echo "ERROR: Unknown build step '${BUILDSTEP}'!" >&2
exit 1
;;
esac
# Report success
echo "All done!"

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.networking.wifi-info</key> <key>com.apple.developer.networking.wifi-info</key>
<true/> <true/>
</dict> </dict>

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cargo build --features flutter --release --target aarch64-apple-ios --lib cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib

View File

@@ -30,8 +30,8 @@ import 'common/widgets/overlay.dart';
import 'mobile/pages/file_manager_page.dart'; import 'mobile/pages/file_manager_page.dart';
import 'mobile/pages/remote_page.dart'; import 'mobile/pages/remote_page.dart';
import 'desktop/pages/remote_page.dart' as desktop_remote; import 'desktop/pages/remote_page.dart' as desktop_remote;
import 'desktop/pages/file_manager_page.dart' as desktop_file_manager;
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'models/input_model.dart';
import 'models/model.dart'; import 'models/model.dart';
import 'models/platform_model.dart'; import 'models/platform_model.dart';
@@ -51,6 +51,9 @@ final isLinux = isLinux_;
final isDesktop = isDesktop_; final isDesktop = isDesktop_;
final isWeb = isWeb_; final isWeb = isWeb_;
final isWebDesktop = isWebDesktop_; final isWebDesktop = isWebDesktop_;
final isWebOnWindows = isWebOnWindows_;
final isWebOnLinux = isWebOnLinux_;
final isWebOnMacOs = isWebOnMacOS_;
var isMobile = isAndroid || isIOS; var isMobile = isAndroid || isIOS;
var version = ''; var version = '';
int androidVersion = 0; int androidVersion = 0;
@@ -348,6 +351,9 @@ class MyTheme {
hoverColor: Color.fromARGB(255, 224, 224, 224), hoverColor: Color.fromARGB(255, 224, 224, 224),
scaffoldBackgroundColor: Colors.white, scaffoldBackgroundColor: Colors.white,
dialogBackgroundColor: Colors.white, dialogBackgroundColor: Colors.white,
appBarTheme: AppBarTheme(
shadowColor: Colors.transparent,
),
dialogTheme: DialogTheme( dialogTheme: DialogTheme(
elevation: 15, elevation: 15,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -443,6 +449,9 @@ class MyTheme {
hoverColor: Color.fromARGB(255, 45, 46, 53), hoverColor: Color.fromARGB(255, 45, 46, 53),
scaffoldBackgroundColor: Color(0xFF18191E), scaffoldBackgroundColor: Color(0xFF18191E),
dialogBackgroundColor: Color(0xFF18191E), dialogBackgroundColor: Color(0xFF18191E),
appBarTheme: AppBarTheme(
shadowColor: Colors.transparent,
),
dialogTheme: DialogTheme( dialogTheme: DialogTheme(
elevation: 15, elevation: 15,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -546,9 +555,9 @@ class MyTheme {
return themeModeFromString(bind.mainGetLocalOption(key: kCommConfKeyTheme)); return themeModeFromString(bind.mainGetLocalOption(key: kCommConfKeyTheme));
} }
static void changeDarkMode(ThemeMode mode) async { static Future<void> changeDarkMode(ThemeMode mode) async {
Get.changeThemeMode(mode); Get.changeThemeMode(mode);
if (desktopType == DesktopType.main || isAndroid || isIOS) { if (desktopType == DesktopType.main || isAndroid || isIOS || isWeb) {
if (mode == ThemeMode.system) { if (mode == ThemeMode.system) {
await bind.mainSetLocalOption( await bind.mainSetLocalOption(
key: kCommConfKeyTheme, value: defaultOptionTheme); key: kCommConfKeyTheme, value: defaultOptionTheme);
@@ -556,7 +565,7 @@ class MyTheme {
await bind.mainSetLocalOption( await bind.mainSetLocalOption(
key: kCommConfKeyTheme, value: mode.toShortString()); key: kCommConfKeyTheme, value: mode.toShortString());
} }
await bind.mainChangeTheme(dark: mode.toShortString()); if (!isWeb) await bind.mainChangeTheme(dark: mode.toShortString());
// Synchronize the window theme of the system. // Synchronize the window theme of the system.
updateSystemWindowTheme(); updateSystemWindowTheme();
} }
@@ -630,10 +639,30 @@ List<Locale> supportedLocales = const [
Locale('da'), Locale('da'),
Locale('eo'), Locale('eo'),
Locale('tr'), Locale('tr'),
Locale('vi'),
Locale('pl'),
Locale('kz'), Locale('kz'),
Locale('es'), Locale('es'),
Locale('nl'),
Locale('nb'),
Locale('et'),
Locale('eu'),
Locale('bg'),
Locale('be'),
Locale('vn'),
Locale('uk'),
Locale('fa'),
Locale('ca'),
Locale('el'),
Locale('sv'),
Locale('sq'),
Locale('sr'),
Locale('th'),
Locale('sl'),
Locale('ro'),
Locale('lt'),
Locale('lv'),
Locale('ar'),
Locale('he'),
Locale('hr'),
]; ];
String formatDurationToTime(Duration duration) { String formatDurationToTime(Duration duration) {
@@ -647,11 +676,17 @@ String formatDurationToTime(Duration duration) {
closeConnection({String? id}) { closeConnection({String? id}) {
if (isAndroid || isIOS) { if (isAndroid || isIOS) {
() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
gFFI.chatModel.hideChatOverlay(); gFFI.chatModel.hideChatOverlay();
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/")); Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
stateGlobal.isInMainPage = true;
}();
} else { } else {
if (isWeb) { if (isWeb) {
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/")); Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
stateGlobal.isInMainPage = true;
} else { } else {
final controller = Get.find<DesktopTabController>(); final controller = Get.find<DesktopTabController>();
controller.closeBy(id); controller.closeBy(id);
@@ -718,7 +753,21 @@ class OverlayDialogManager {
int _tagCount = 0; int _tagCount = 0;
OverlayEntry? _mobileActionsOverlayEntry; OverlayEntry? _mobileActionsOverlayEntry;
RxBool mobileActionsOverlayVisible = false.obs; RxBool mobileActionsOverlayVisible = true.obs;
setMobileActionsOverlayVisible(bool v, {store = true}) {
if (store) {
bind.setLocalFlutterOption(k: kOptionShowMobileAction, v: v ? 'Y' : 'N');
}
// No need to read the value from local storage after setting it.
// It better to toggle the value directly.
mobileActionsOverlayVisible.value = v;
}
loadMobileActionsOverlayVisible() {
mobileActionsOverlayVisible.value =
bind.getLocalFlutterOption(k: kOptionShowMobileAction) != 'N';
}
void setOverlayState(OverlayKeyState overlayKeyState) { void setOverlayState(OverlayKeyState overlayKeyState) {
_overlayKeyState = overlayKeyState; _overlayKeyState = overlayKeyState;
@@ -865,14 +914,14 @@ class OverlayDialogManager {
); );
overlayState.insert(overlay); overlayState.insert(overlay);
_mobileActionsOverlayEntry = overlay; _mobileActionsOverlayEntry = overlay;
mobileActionsOverlayVisible.value = true; setMobileActionsOverlayVisible(true);
} }
void hideMobileActionsOverlay() { void hideMobileActionsOverlay({store = true}) {
if (_mobileActionsOverlayEntry != null) { if (_mobileActionsOverlayEntry != null) {
_mobileActionsOverlayEntry!.remove(); _mobileActionsOverlayEntry!.remove();
_mobileActionsOverlayEntry = null; _mobileActionsOverlayEntry = null;
mobileActionsOverlayVisible.value = false; setMobileActionsOverlayVisible(false, store: store);
return; return;
} }
} }
@@ -891,30 +940,32 @@ class OverlayDialogManager {
} }
makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) { makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
final position = SimpleWrapper(Offset(0, 0));
makeMobileActions(BuildContext context, double s) { makeMobileActions(BuildContext context, double s) {
final scale = s < 0.85 ? 0.85 : s; final scale = s < 0.85 ? 0.85 : s;
final session = ffi ?? gFFI; final session = ffi ?? gFFI;
// compute overlay position
final screenW = MediaQuery.of(context).size.width;
final screenH = MediaQuery.of(context).size.height;
const double overlayW = 200; const double overlayW = 200;
const double overlayH = 45; const double overlayH = 45;
computeOverlayPosition() {
final screenW = MediaQuery.of(context).size.width;
final screenH = MediaQuery.of(context).size.height;
final left = (screenW - overlayW * scale) / 2; final left = (screenW - overlayW * scale) / 2;
final top = screenH - (overlayH + 80) * scale; final top = screenH - (overlayH + 80) * scale;
position.value = Offset(left, top); return Offset(left, top);
}
if (draggablePositions.mobileActions.isInvalid()) {
draggablePositions.mobileActions.update(computeOverlayPosition());
} else {
draggablePositions.mobileActions.tryAdjust(overlayW, overlayH, scale);
}
return DraggableMobileActions( return DraggableMobileActions(
scale: scale, scale: scale,
position: position, position: draggablePositions.mobileActions,
width: overlayW, width: overlayW,
height: overlayH, height: overlayH,
onBackPressed: () => session.inputModel.tap(MouseButtons.right), onBackPressed: session.inputModel.onMobileBack,
onHomePressed: () => session.inputModel.tap(MouseButtons.wheel), onHomePressed: session.inputModel.onMobileHome,
onRecentPressed: () async { onRecentPressed: session.inputModel.onMobileApps,
session.inputModel.sendMouse('down', MouseButtons.wheel);
await Future.delayed(const Duration(milliseconds: 500));
session.inputModel.sendMouse('up', MouseButtons.wheel);
},
onHidePressed: onHide, onHidePressed: onHide,
); );
} }
@@ -1008,7 +1059,8 @@ class CustomAlertDialog extends StatelessWidget {
return KeyEventResult.handled; // avoid TextField exception on escape return KeyEventResult.handled; // avoid TextField exception on escape
} else if (!tabTapped && } else if (!tabTapped &&
onSubmit != null && onSubmit != null &&
(key.logicalKey == LogicalKeyboardKey.enter || key.logicalKey == LogicalKeyboardKey.numpadEnter)) { (key.logicalKey == LogicalKeyboardKey.enter ||
key.logicalKey == LogicalKeyboardKey.numpadEnter)) {
if (key is RawKeyDownEvent) onSubmit?.call(); if (key is RawKeyDownEvent) onSubmit?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.tab) { } else if (key.logicalKey == LogicalKeyboardKey.tab) {
@@ -1037,6 +1089,49 @@ class CustomAlertDialog extends StatelessWidget {
} }
} }
Widget createDialogContent(String text) {
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
final List<TextSpan> spans = [];
int start = 0;
bool hasLink = false;
linkRegExp.allMatches(text).forEach((match) {
hasLink = true;
if (match.start > start) {
spans.add(TextSpan(text: text.substring(start, match.start)));
}
spans.add(TextSpan(
text: match.group(0) ?? '',
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
String linkText = match.group(0) ?? '';
linkText = linkText.replaceAll(RegExp(r'[.,;!?]+$'), '');
launchUrl(Uri.parse(linkText));
},
));
start = match.end;
});
if (start < text.length) {
spans.add(TextSpan(text: text.substring(start)));
}
if (!hasLink) {
return SelectableText(text, style: const TextStyle(fontSize: 15));
}
return SelectableText.rich(
TextSpan(
style: TextStyle(color: Colors.black, fontSize: 15),
children: spans,
),
);
}
void msgBox(SessionID sessionId, String type, String title, String text, void msgBox(SessionID sessionId, String type, String title, String text,
String link, OverlayDialogManager dialogManager, String link, OverlayDialogManager dialogManager,
{bool? hasCancel, ReconnectHandle? reconnect, int? reconnectTimeout}) { {bool? hasCancel, ReconnectHandle? reconnect, int? reconnectTimeout}) {
@@ -1045,7 +1140,7 @@ void msgBox(SessionID sessionId, String type, String title, String text,
bool hasOk = false; bool hasOk = false;
submit() { submit() {
dialogManager.dismissAll(); dialogManager.dismissAll();
// https://github.com/fufesou/rustdesk/blob/5e9a31340b899822090a3731769ae79c6bf5f3e5/src/ui/common.tis#L263 // https://github.com/rustdesk/rustdesk/blob/5e9a31340b899822090a3731769ae79c6bf5f3e5/src/ui/common.tis#L263
if (!type.contains("custom") && desktopType != DesktopType.portForward) { if (!type.contains("custom") && desktopType != DesktopType.portForward) {
closeConnection(); closeConnection();
} }
@@ -1079,12 +1174,11 @@ void msgBox(SessionID sessionId, String type, String title, String text,
dialogManager.dismissAll(); dialogManager.dismissAll();
})); }));
} }
if (reconnect != null && if (reconnect != null && title == "Connection Error") {
title == "Connection Error" &&
reconnectTimeout != null) {
// `enabled` is used to disable the dialog button once the button is clicked. // `enabled` is used to disable the dialog button once the button is clicked.
final enabled = true.obs; final enabled = true.obs;
final button = Obx(() => _ReconnectCountDownButton( final button = reconnectTimeout != null
? Obx(() => _ReconnectCountDownButton(
second: reconnectTimeout, second: reconnectTimeout,
onPressed: enabled.isTrue onPressed: enabled.isTrue
? () { ? () {
@@ -1093,7 +1187,20 @@ void msgBox(SessionID sessionId, String type, String title, String text,
reconnect(dialogManager, sessionId, false); reconnect(dialogManager, sessionId, false);
} }
: null, : null,
)); ))
: Obx(
() => dialogButton(
'Reconnect',
isOutline: true,
onPressed: enabled.isTrue
? () {
// Disable the button
enabled.value = false;
reconnect(dialogManager, sessionId, false);
}
: null,
),
);
buttons.insert(0, button); buttons.insert(0, button);
} }
if (link.isNotEmpty) { if (link.isNotEmpty) {
@@ -1182,7 +1289,7 @@ Widget msgboxContent(String type, String title, String text) {
translate(title), translate(title),
style: TextStyle(fontSize: 21), style: TextStyle(fontSize: 21),
).marginOnly(bottom: 10), ).marginOnly(bottom: 10),
Text(translateText(text), style: const TextStyle(fontSize: 15)), createDialogContent(translateText(text)),
], ],
), ),
), ),
@@ -1377,14 +1484,10 @@ class AndroidPermissionManager {
} }
} }
// TODO move this to mobile/widgets.
// Used only for mobile, pages remote, settings, dialog
// TODO remove argument contentPadding, its not used, getToggle() has not
RadioListTile<T> getRadio<T>( RadioListTile<T> getRadio<T>(
Widget title, T toValue, T curValue, ValueChanged<T?>? onChange, Widget title, T toValue, T curValue, ValueChanged<T?>? onChange,
{EdgeInsetsGeometry? contentPadding, bool? dense}) { {bool? dense}) {
return RadioListTile<T>( return RadioListTile<T>(
contentPadding: contentPadding ?? EdgeInsets.zero,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
controlAffinity: ListTileControlAffinity.trailing, controlAffinity: ListTileControlAffinity.trailing,
title: title, title: title,
@@ -1411,7 +1514,7 @@ Future<void> initGlobalFFI() async {
_globalFFI = FFI(null); _globalFFI = FFI(null);
debugPrint("_globalFFI init end"); debugPrint("_globalFFI init end");
// after `put`, can also be globally found by Get.find<FFI>(); // after `put`, can also be globally found by Get.find<FFI>();
Get.put(_globalFFI, permanent: true); Get.put<FFI>(_globalFFI, permanent: true);
} }
String translate(String name) { String translate(String name) {
@@ -1421,14 +1524,16 @@ String translate(String name) {
return platformFFI.translate(name, localeName); return platformFFI.translate(name, localeName);
} }
// This function must be kept the same as the one in rust and sciter code.
// rust: libs/hbb_common/src/config.rs -> option2bool()
// sciter: Does not have the function, but it should be kept the same.
bool option2bool(String option, String value) { bool option2bool(String option, String value) {
bool res; bool res;
if (option.startsWith("enable-")) { if (option.startsWith("enable-")) {
res = value != "N"; res = value != "N";
} else if (option.startsWith("allow-") || } else if (option.startsWith("allow-") ||
option == "stop-service" || option == kOptionStopService ||
option == kOptionDirectServer || option == kOptionDirectServer ||
option == "stop-rendezvous-service" ||
option == kOptionForceAlwaysRelay) { option == kOptionForceAlwaysRelay) {
res = value == "Y"; res = value == "Y";
} else { } else {
@@ -1443,9 +1548,8 @@ String bool2option(String option, bool b) {
if (option.startsWith('enable-')) { if (option.startsWith('enable-')) {
res = b ? defaultOptionYes : 'N'; res = b ? defaultOptionYes : 'N';
} else if (option.startsWith('allow-') || } else if (option.startsWith('allow-') ||
option == "stop-service" || option == kOptionStopService ||
option == kOptionDirectServer || option == kOptionDirectServer ||
option == "stop-rendezvous-service" ||
option == kOptionForceAlwaysRelay) { option == kOptionForceAlwaysRelay) {
res = b ? 'Y' : defaultOptionNo; res = b ? 'Y' : defaultOptionNo;
} else { } else {
@@ -1481,9 +1585,9 @@ bool mainGetPeerBoolOptionSync(String id, String key) {
return option2bool(key, bind.mainGetPeerOptionSync(id: id, key: key)); return option2bool(key, bind.mainGetPeerOptionSync(id: id, key: key));
} }
mainSetPeerBoolOptionSync(String id, String key, bool v) { // Don't use `option2bool()` and `bool2option()` to convert the session option.
bind.mainSetPeerOptionSync(id: id, key: key, value: bool2option(key, v)); // 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) async {
if (searchText.isEmpty) { if (searchText.isEmpty) {
@@ -1934,6 +2038,8 @@ Future<bool> restoreWindowPosition(WindowType type,
return false; return false;
} }
var webInitialLink = "";
/// Initialize uni links for macos/windows /// Initialize uni links for macos/windows
/// ///
/// [Availability] /// [Availability]
@@ -1950,7 +2056,12 @@ Future<bool> initUniLinks() async {
if (initialLink == null || initialLink.isEmpty) { if (initialLink == null || initialLink.isEmpty) {
return false; return false;
} }
if (isWeb) {
webInitialLink = initialLink;
return false;
} else {
return handleUriLink(uriString: initialLink); return handleUriLink(uriString: initialLink);
}
} catch (err) { } catch (err) {
debugPrintStack(label: "$err"); debugPrintStack(label: "$err");
return false; return false;
@@ -1963,7 +2074,7 @@ Future<bool> initUniLinks() async {
/// ///
/// Returns a [StreamSubscription] which can listen the uni links. /// Returns a [StreamSubscription] which can listen the uni links.
StreamSubscription? listenUniLinks({handleByFlutter = true}) { StreamSubscription? listenUniLinks({handleByFlutter = true}) {
if (isLinux) { if (isLinux || isWeb) {
return null; return null;
} }
@@ -2267,11 +2378,25 @@ connect(BuildContext context, String id,
} }
} else { } else {
if (isFileTransfer) { if (isFileTransfer) {
if (isAndroid) {
if (!await AndroidPermissionManager.check(kManageExternalStorage)) { if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
if (!await AndroidPermissionManager.request(kManageExternalStorage)) { if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
return; return;
} }
} }
}
if (isWeb) {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) =>
desktop_file_manager.FileManagerPage(
id: id,
password: password,
isSharedPassword: isSharedPassword),
),
);
} else {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -2279,8 +2404,9 @@ connect(BuildContext context, String id,
id: id, password: password, isSharedPassword: isSharedPassword), id: id, password: password, isSharedPassword: isSharedPassword),
), ),
); );
}
} else { } else {
if (isWebDesktop) { if (isWeb) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -2304,6 +2430,7 @@ connect(BuildContext context, String id,
); );
} }
} }
stateGlobal.isInMainPage = false;
} }
FocusScopeNode currentFocus = FocusScope.of(context); FocusScopeNode currentFocus = FocusScope.of(context);
@@ -2668,15 +2795,18 @@ Future<void> start_service(bool is_start) async {
!isMacOS || !isMacOS ||
await callMainCheckSuperUserPermission(); await callMainCheckSuperUserPermission();
if (checked) { if (checked) {
bind.mainSetOption(key: "stop-service", value: is_start ? "" : "Y"); mainSetBoolOption(kOptionStopService, !is_start);
} }
} }
typedef WhetherUseRemoteBlock = Future<bool> Function(); Future<bool> canBeBlocked() async {
Widget buildRemoteBlock({required Widget child, WhetherUseRemoteBlock? use}) { var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
var block = false.obs; var option = option2bool(kOptionAllowRemoteConfigModification,
return Obx(() => MouseRegion( await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
onEnter: (_) async { return access_mode == 'view' || (access_mode.isEmpty && !option);
}
Future<void> shouldBeBlocked(RxBool block, WhetherUseRemoteBlock? use) async {
if (use != null && !await use()) { if (use != null && !await use()) {
block.value = false; block.value = false;
return; return;
@@ -2687,12 +2817,28 @@ Widget buildRemoteBlock({required Widget child, WhetherUseRemoteBlock? use}) {
var d = time0 - await bind.mainGetMouseTime(); var d = time0 - await bind.mainGetMouseTime();
if (d < 120) { if (d < 120) {
block.value = true; block.value = true;
} else {
block.value = false;
} }
}); });
}
typedef WhetherUseRemoteBlock = Future<bool> Function();
Widget buildRemoteBlock(
{required Widget child,
required RxBool block,
required bool mask,
WhetherUseRemoteBlock? use}) {
return Obx(() => MouseRegion(
onEnter: (_) async {
await shouldBeBlocked(block, use);
}, },
onExit: (event) => block.value = false, onExit: (event) => block.value = false,
child: Stack(children: [ child: Stack(children: [
child, // scope block tab
FocusScope(child: child, canRequestFocus: !block.value),
// mask block click, cm not block click and still use check_click_time to avoid block local click
if (mask)
Offstage( Offstage(
offstage: !block.value, offstage: !block.value,
child: Container( child: Container(
@@ -2745,12 +2891,10 @@ Widget buildErrorBanner(BuildContext context,
required RxString err, required RxString err,
required Function? retry, required Function? retry,
required Function close}) { required Function close}) {
const double height = 25;
return Obx(() => Offstage( return Obx(() => Offstage(
offstage: !(!loading.value && err.value.isNotEmpty), offstage: !(!loading.value && err.value.isNotEmpty),
child: Center( child: Center(
child: Container( child: Container(
height: height,
color: MyTheme.color(context).errorBannerBg, color: MyTheme.color(context).errorBannerBg,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -2767,9 +2911,8 @@ Widget buildErrorBanner(BuildContext context,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Tooltip( child: Tooltip(
message: translate(err.value), message: translate(err.value),
child: Text( child: SelectableText(
translate(err.value), translate(err.value),
overflow: TextOverflow.ellipsis,
), ),
)).marginSymmetric(vertical: 2), )).marginSymmetric(vertical: 2),
), ),
@@ -2889,6 +3032,16 @@ openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
final displays = i == kAllDisplayValue final displays = i == kAllDisplayValue
? List.generate(pi.displays.length, (index) => index) ? List.generate(pi.displays.length, (index) => index)
: [i]; : [i];
// Try clear image model before switching from all displays
// 1. The remote side has multiple displays.
// 2. Do not use texture render.
// 3. Connect to Display 1.
// 4. Switch to multi-displays `kAllDisplayValue`
// 5. Switch to Display 2.
// Then the remote page will display last picture of Display 1 at the beginning.
if (pi.forceTextureRender && i != kAllDisplayValue) {
ffi.imageModel.clearImage();
}
bind.sessionSwitchDisplay( bind.sessionSwitchDisplay(
isDesktop: isDesktop, isDesktop: isDesktop,
sessionId: ffi.sessionId, sessionId: ffi.sessionId,
@@ -2922,10 +3075,15 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
kMainWindowId, kWindowEventOpenMonitorSession, jsonEncode(args)); kMainWindowId, kWindowEventOpenMonitorSession, jsonEncode(args));
} }
setNewConnectWindowFrame(int windowId, String peerId, Rect? screenRect) async { setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount,
int? display, Rect? screenRect) async {
if (screenRect == null) { if (screenRect == null) {
// Do not restore window position to new connection if there's a pre-session.
// https://github.com/rustdesk/rustdesk/discussions/8825
if (preSessionCount == 0) {
await restoreWindowPosition(WindowType.RemoteDesktop, await restoreWindowPosition(WindowType.RemoteDesktop,
windowId: windowId, peerId: peerId); windowId: windowId, display: display, peerId: peerId);
}
} else { } else {
await tryMoveToScreenAndSetFullscreen(screenRect); await tryMoveToScreenAndSetFullscreen(screenRect);
} }
@@ -3022,9 +3180,13 @@ class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> {
importConfig(List<TextEditingController>? controllers, List<RxString>? errMsgs, importConfig(List<TextEditingController>? controllers, List<RxString>? errMsgs,
String? text) { String? text) {
text = text?.trim();
if (text != null && text.isNotEmpty) { if (text != null && text.isNotEmpty) {
try { try {
final sc = ServerConfig.decode(text); final sc = ServerConfig.decode(text);
if (isWeb || isIOS) {
sc.relayServer = '';
}
if (sc.idServer.isNotEmpty) { if (sc.idServer.isNotEmpty) {
Future<bool> success = setServerConfig(controllers, errMsgs, sc); Future<bool> success = setServerConfig(controllers, errMsgs, sc);
success.then((value) { success.then((value) {
@@ -3051,9 +3213,16 @@ Future<bool> setServerConfig(
List<RxString>? errMsgs, List<RxString>? errMsgs,
ServerConfig config, ServerConfig config,
) async { ) async {
config.idServer = config.idServer.trim(); String removeEndSlash(String input) {
config.relayServer = config.relayServer.trim(); if (input.endsWith('/')) {
config.apiServer = config.apiServer.trim(); return input.substring(0, input.length - 1);
}
return input;
}
config.idServer = removeEndSlash(config.idServer.trim());
config.relayServer = removeEndSlash(config.relayServer.trim());
config.apiServer = removeEndSlash(config.apiServer.trim());
config.key = config.key.trim(); config.key = config.key.trim();
if (controllers != null) { if (controllers != null) {
controllers[0].text = config.idServer; controllers[0].text = config.idServer;
@@ -3262,16 +3431,11 @@ bool isInHomePage() {
return controller.state.value.selected == 0; return controller.state.value.selected == 0;
} }
Widget buildPresetPasswordWarning() { Widget _buildPresetPasswordWarning() {
return FutureBuilder<bool>( if (bind.mainGetBuildinOption(key: kOptionRemovePresetPasswordWarning) !=
future: bind.isPresetPassword(), 'N') {
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) { return SizedBox.shrink();
if (snapshot.connectionState == ConnectionState.waiting) { }
return CircularProgressIndicator(); // Show a loading spinner while waiting for the Future to complete
} else if (snapshot.hasError) {
return Text(
'Error: ${snapshot.error}'); // Show an error message if the Future completed with an error
} else if (snapshot.hasData && snapshot.data == true) {
return Container( return Container(
color: Colors.yellow, color: Colors.yellow,
child: Column( child: Column(
@@ -3293,6 +3457,27 @@ Widget buildPresetPasswordWarning() {
], ],
).paddingAll(8), ).paddingAll(8),
); // Show a warning message if the Future completed with true ); // Show a warning message if the Future completed with true
}
Widget buildPresetPasswordWarningMobile() {
if (bind.isPresetPasswordMobileOnly()) {
return _buildPresetPasswordWarning();
} else {
return SizedBox.shrink();
}
}
Widget buildPresetPasswordWarning() {
return FutureBuilder<bool>(
future: bind.isPresetPassword(),
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator(); // Show a loading spinner while waiting for the Future to complete
} else if (snapshot.hasError) {
return Text(
'Error: ${snapshot.error}'); // Show an error message if the Future completed with an error
} else if (snapshot.hasData && snapshot.data == true) {
return _buildPresetPasswordWarning();
} else { } else {
return SizedBox return SizedBox
.shrink(); // Show nothing if the Future completed with false or null .shrink(); // Show nothing if the Future completed with false or null
@@ -3346,7 +3531,8 @@ Widget buildVirtualWindowFrame(BuildContext context, Widget child) {
); );
} }
get windowEdgeSize => isLinux && !_linuxWindowResizable ? 0.0 : kWindowEdgeSize; get windowResizeEdgeSize =>
isLinux && !_linuxWindowResizable ? 0.0 : kWindowResizeEdgeSize;
// `windowManager.setResizable(false)` will reset the window size to the default size on Linux and then set unresizable. // `windowManager.setResizable(false)` will reset the window size to the default size on Linux and then set unresizable.
// See _linuxWindowResizable for more details. // See _linuxWindowResizable for more details.
@@ -3364,7 +3550,12 @@ setResizable(bool resizable) {
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key); isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
final isCustomClient = bind.isCustomClient(); bool? _isCustomClient;
bool get isCustomClient {
_isCustomClient ??= bind.isCustomClient();
return _isCustomClient!;
}
get defaultOptionLang => isCustomClient ? 'default' : ''; get defaultOptionLang => isCustomClient ? 'default' : '';
get defaultOptionTheme => isCustomClient ? 'system' : ''; get defaultOptionTheme => isCustomClient ? 'system' : '';
get defaultOptionYes => isCustomClient ? 'Y' : ''; get defaultOptionYes => isCustomClient ? 'Y' : '';
@@ -3372,3 +3563,70 @@ get defaultOptionNo => isCustomClient ? 'N' : '';
get defaultOptionWhitelist => isCustomClient ? ',' : ''; get defaultOptionWhitelist => isCustomClient ? ',' : '';
get defaultOptionAccessMode => isCustomClient ? 'custom' : ''; get defaultOptionAccessMode => isCustomClient ? 'custom' : '';
get defaultOptionApproveMode => isCustomClient ? 'password-click' : ''; get defaultOptionApproveMode => isCustomClient ? 'password-click' : '';
bool whitelistNotEmpty() {
// https://rustdesk.com/docs/en/self-host/client-configuration/advanced-settings/#whitelist
final v = bind.mainGetOptionSync(key: kOptionWhitelist);
return v != '' && v != ',';
}
// `setMovable()` is only supported on macOS.
//
// On macOS, the window can be dragged by the tab bar by default.
// We need to disable the movable feature to prevent the window from being dragged by the tabs in the tab bar.
//
// When we drag the blank tab bar (not the tab), the window will be dragged normally by adding the `onPanStart` handle.
//
// See the following code for more details:
// https://github.com/rustdesk/rustdesk/blob/ce1dac3b8613596b4d8ae981275f9335489eb935/flutter/lib/desktop/widgets/tabbar_widget.dart#L385
// https://github.com/rustdesk/rustdesk/blob/ce1dac3b8613596b4d8ae981275f9335489eb935/flutter/lib/desktop/widgets/tabbar_widget.dart#L399
//
// @platforms macos
disableWindowMovable(int? windowId) {
if (!isMacOS) {
return;
}
if (windowId == null) {
windowManager.setMovable(false);
} else {
WindowController.fromWindowId(windowId).setMovable(false);
}
}
Widget netWorkErrorWidget() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(translate("network_error_tip")),
ElevatedButton(
onPressed: gFFI.userModel.refreshCurrentUser,
child: Text(translate("Retry")))
.marginSymmetric(vertical: 16),
SelectableText(gFFI.userModel.networkError.value,
style: TextStyle(fontSize: 11, color: Colors.red)),
],
));
}
List<ResizeEdge>? get windowManagerEnableResizeEdges => isWindows
? [
ResizeEdge.topLeft,
ResizeEdge.top,
ResizeEdge.topRight,
]
: null;
List<SubWindowResizeEdge>? get subWindowManagerEnableResizeEdges => isWindows
? [
SubWindowResizeEdge.topLeft,
SubWindowResizeEdge.top,
SubWindowResizeEdge.topRight,
]
: null;
void earlyAssert() {
assert('\1' == '1');
}

View File

@@ -10,16 +10,16 @@ class PrivacyModeState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxString>(tag: key)) {
final RxString state = ''.obs; final RxString state = ''.obs;
Get.put(state, tag: key); Get.put<RxString>(state, tag: key);
} }
} }
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxString>(tag: key)) {
Get.delete(tag: key); Get.delete<RxString>(tag: key);
} else { } else {
Get.find<RxString>(tag: key).value = ''; Get.find<RxString>(tag: key).value = '';
} }
@@ -33,9 +33,9 @@ class BlockInputState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs; final RxBool state = false.obs;
Get.put(state, tag: key); Get.put<RxBool>(state, tag: key);
} else { } else {
Get.find<RxBool>(tag: key).value = false; Get.find<RxBool>(tag: key).value = false;
} }
@@ -43,8 +43,8 @@ class BlockInputState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete(tag: key); Get.delete<RxBool>(tag: key);
} }
} }
@@ -56,9 +56,9 @@ class CurrentDisplayState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = RxInt(0); final RxInt state = RxInt(0);
Get.put(state, tag: key); Get.put<RxInt>(state, tag: key);
} else { } else {
Get.find<RxInt>(tag: key).value = 0; Get.find<RxInt>(tag: key).value = 0;
} }
@@ -66,8 +66,8 @@ class CurrentDisplayState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete(tag: key); Get.delete<RxInt>(tag: key);
} }
} }
@@ -105,16 +105,16 @@ class ConnectionTypeState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<ConnectionType>(tag: key)) {
final ConnectionType collectionType = ConnectionType(); final ConnectionType collectionType = ConnectionType();
Get.put(collectionType, tag: key); Get.put<ConnectionType>(collectionType, tag: key);
} }
} }
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<ConnectionType>(tag: key)) {
Get.delete(tag: key); Get.delete<ConnectionType>(tag: key);
} }
} }
@@ -127,9 +127,9 @@ class FingerprintState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxString>(tag: key)) {
final RxString state = ''.obs; final RxString state = ''.obs;
Get.put(state, tag: key); Get.put<RxString>(state, tag: key);
} else { } else {
Get.find<RxString>(tag: key).value = ''; Get.find<RxString>(tag: key).value = '';
} }
@@ -137,8 +137,8 @@ class FingerprintState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxString>(tag: key)) {
Get.delete(tag: key); Get.delete<RxString>(tag: key);
} }
} }
@@ -150,9 +150,9 @@ class ShowRemoteCursorState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs; final RxBool state = false.obs;
Get.put(state, tag: key); Get.put<RxBool>(state, tag: key);
} else { } else {
Get.find<RxBool>(tag: key).value = false; Get.find<RxBool>(tag: key).value = false;
} }
@@ -160,8 +160,8 @@ class ShowRemoteCursorState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete(tag: key); Get.delete<RxBool>(tag: key);
} }
} }
@@ -173,9 +173,9 @@ class ShowRemoteCursorLockState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs; final RxBool state = false.obs;
Get.put(state, tag: key); Get.put<RxBool>(state, tag: key);
} else { } else {
Get.find<RxBool>(tag: key).value = false; Get.find<RxBool>(tag: key).value = false;
} }
@@ -183,8 +183,8 @@ class ShowRemoteCursorLockState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete(tag: key); Get.delete<RxBool>(tag: key);
} }
} }
@@ -196,10 +196,10 @@ class KeyboardEnabledState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxBool>(tag: key)) {
// Server side, default true // Server side, default true
final RxBool state = true.obs; final RxBool state = true.obs;
Get.put(state, tag: key); Get.put<RxBool>(state, tag: key);
} else { } else {
Get.find<RxBool>(tag: key).value = true; Get.find<RxBool>(tag: key).value = true;
} }
@@ -207,8 +207,8 @@ class KeyboardEnabledState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete(tag: key); Get.delete<RxBool>(tag: key);
} }
} }
@@ -220,9 +220,9 @@ class RemoteCursorMovedState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs; final RxBool state = false.obs;
Get.put(state, tag: key); Get.put<RxBool>(state, tag: key);
} else { } else {
Get.find<RxBool>(tag: key).value = false; Get.find<RxBool>(tag: key).value = false;
} }
@@ -230,8 +230,8 @@ class RemoteCursorMovedState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete(tag: key); Get.delete<RxBool>(tag: key);
} }
} }
@@ -243,9 +243,9 @@ class RemoteCountState {
static void init() { static void init() {
final key = tag(); final key = tag();
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = 1.obs; final RxInt state = 1.obs;
Get.put(state, tag: key); Get.put<RxInt>(state, tag: key);
} else { } else {
Get.find<RxInt>(tag: key).value = 1; Get.find<RxInt>(tag: key).value = 1;
} }
@@ -253,8 +253,8 @@ class RemoteCountState {
static void delete() { static void delete() {
final key = tag(); final key = tag();
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete(tag: key); Get.delete<RxInt>(tag: key);
} }
} }
@@ -266,9 +266,9 @@ class PeerBoolOption {
static void init(String id, String opt, bool Function() init_getter) { static void init(String id, String opt, bool Function() init_getter) {
final key = tag(id, opt); final key = tag(id, opt);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool value = RxBool(init_getter()); final RxBool value = RxBool(init_getter());
Get.put(value, tag: key); Get.put<RxBool>(value, tag: key);
} else { } else {
Get.find<RxBool>(tag: key).value = init_getter(); Get.find<RxBool>(tag: key).value = init_getter();
} }
@@ -276,8 +276,8 @@ class PeerBoolOption {
static void delete(String id, String opt) { static void delete(String id, String opt) {
final key = tag(id, opt); final key = tag(id, opt);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete(tag: key); Get.delete<RxBool>(tag: key);
} }
} }
@@ -290,9 +290,9 @@ class PeerStringOption {
static void init(String id, String opt, String Function() init_getter) { static void init(String id, String opt, String Function() init_getter) {
final key = tag(id, opt); final key = tag(id, opt);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxString>(tag: key)) {
final RxString value = RxString(init_getter()); final RxString value = RxString(init_getter());
Get.put(value, tag: key); Get.put<RxString>(value, tag: key);
} else { } else {
Get.find<RxString>(tag: key).value = init_getter(); Get.find<RxString>(tag: key).value = init_getter();
} }
@@ -300,8 +300,8 @@ class PeerStringOption {
static void delete(String id, String opt) { static void delete(String id, String opt) {
final key = tag(id, opt); final key = tag(id, opt);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxString>(tag: key)) {
Get.delete(tag: key); Get.delete<RxString>(tag: key);
} }
} }
@@ -314,9 +314,9 @@ class UnreadChatCountState {
static void init(String id) { static void init(String id) {
final key = tag(id); final key = tag(id);
if (!Get.isRegistered(tag: key)) { if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = RxInt(0); final RxInt state = RxInt(0);
Get.put(state, tag: key); Get.put<RxInt>(state, tag: key);
} else { } else {
Get.find<RxInt>(tag: key).value = 0; Get.find<RxInt>(tag: key).value = 0;
} }
@@ -324,8 +324,8 @@ class UnreadChatCountState {
static void delete(String id) { static void delete(String id) {
final key = tag(id); final key = tag(id);
if (Get.isRegistered(tag: key)) { if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete(tag: key); Get.delete<RxInt>(tag: key);
} }
} }

View File

@@ -11,6 +11,7 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart'; import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
import 'package:flutter_hbb/models/ab_model.dart'; import 'package:flutter_hbb/models/ab_model.dart';
import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -35,17 +36,14 @@ class AddressBook extends StatefulWidget {
class _AddressBookState extends State<AddressBook> { class _AddressBookState extends State<AddressBook> {
var menuPos = RelativeRect.fill; var menuPos = RelativeRect.fill;
@override
void initState() {
super.initState();
}
@override @override
Widget build(BuildContext context) => Obx(() { Widget build(BuildContext context) => Obx(() {
if (!gFFI.userModel.isLogin) { if (!gFFI.userModel.isLogin) {
return Center( return Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: loginDialog, child: Text(translate("Login")))); onPressed: loginDialog, child: Text(translate("Login"))));
} else if (gFFI.userModel.networkError.isNotEmpty) {
return netWorkErrorWidget();
} else { } else {
return Column( return Column(
children: [ children: [
@@ -64,15 +62,16 @@ class _AddressBookState extends State<AddressBook> {
retry: null, // remove retry retry: null, // remove retry
close: () => gFFI.abModel.currentAbPushError.value = ''), close: () => gFFI.abModel.currentAbPushError.value = ''),
Expanded( Expanded(
child: (isDesktop || isWebDesktop) child: Obx(() => stateGlobal.isPortrait.isTrue
? _buildAddressBookDesktop() ? _buildAddressBookPortrait()
: _buildAddressBookMobile()) : _buildAddressBookLandscape()),
),
], ],
); );
} }
}); });
Widget _buildAddressBookDesktop() { Widget _buildAddressBookLandscape() {
return Row( return Row(
children: [ children: [
Offstage( Offstage(
@@ -109,7 +108,8 @@ class _AddressBookState extends State<AddressBook> {
); );
} }
Widget _buildAddressBookMobile() { Widget _buildAddressBookPortrait() {
const padding = 8.0;
return Column( return Column(
children: [ children: [
Offstage( Offstage(
@@ -120,7 +120,8 @@ class _AddressBookState extends State<AddressBook> {
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.background)), color: Theme.of(context).colorScheme.background)),
child: Container( child: Container(
padding: const EdgeInsets.all(8.0), padding:
const EdgeInsets.fromLTRB(padding, 0, padding, padding),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -130,7 +131,6 @@ class _AddressBookState extends State<AddressBook> {
width: double.infinity, width: double.infinity,
child: _buildTags(), child: _buildTags(),
), ),
_buildAbPermission(),
], ],
), ),
), ),
@@ -198,10 +198,9 @@ class _AddressBookState extends State<AddressBook> {
if (contains) { if (contains) {
names.insert(0, personalAddressBookName); names.insert(0, personalAddressBookName);
} }
final items = names
.map((e) => DropdownMenuItem( Row buildItem(String e, {bool button = false}) {
value: e, return Row(
child: Row(
children: [ children: [
Expanded( Expanded(
child: Tooltip( child: Tooltip(
@@ -209,13 +208,18 @@ class _AddressBookState extends State<AddressBook> {
message: gFFI.abModel.translatedName(e), message: gFFI.abModel.translatedName(e),
child: Text( child: Text(
gFFI.abModel.translatedName(e), gFFI.abModel.translatedName(e),
style: TextStyle(fontSize: 14.0), style: button ? null : TextStyle(fontSize: 14.0),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: button ? TextAlign.center : null,
)), )),
), ),
], ],
))) );
}
final items = names
.map((e) => DropdownMenuItem(value: e, child: buildItem(e)))
.toList(); .toList();
var menuItemStyleData = MenuItemStyleData(height: 36); var menuItemStyleData = MenuItemStyleData(height: 36);
if (contains && items.length > 1) { if (contains && items.length > 1) {
@@ -237,14 +241,23 @@ class _AddressBookState extends State<AddressBook> {
bind.setLocalFlutterOption(k: kOptionCurrentAbName, v: value); bind.setLocalFlutterOption(k: kOptionCurrentAbName, v: value);
} }
}, },
customButton: Obx(() => Container(
height: stateGlobal.isPortrait.isFalse ? 48 : 40,
child: Row(children: [
Expanded(
child:
buildItem(gFFI.abModel.currentName.value, button: true)),
Icon(Icons.arrow_drop_down),
]),
)),
underline: Container( underline: Container(
height: 0.7, height: 0.7,
color: Theme.of(context).dividerColor.withOpacity(0.1), color: Theme.of(context).dividerColor.withOpacity(0.1),
), ),
buttonStyleData: ButtonStyleData(height: 48),
menuItemStyleData: menuItemStyleData, menuItemStyleData: menuItemStyleData,
items: items, items: items,
isExpanded: true, isExpanded: true,
isDense: true,
dropdownSearchData: DropdownSearchData( dropdownSearchData: DropdownSearchData(
searchController: textEditingController, searchController: textEditingController,
searchInnerWidgetHeight: 50, searchInnerWidgetHeight: 50,
@@ -325,8 +338,8 @@ class _AddressBookState extends State<AddressBook> {
showActionMenu: editPermission); showActionMenu: editPermission);
} }
final gridView = DynamicGridView.builder( gridView(bool isPortrait) => DynamicGridView.builder(
shrinkWrap: isMobile, shrinkWrap: isPortrait,
gridDelegate: SliverGridDelegateWithWrapping(), gridDelegate: SliverGridDelegateWithWrapping(),
itemCount: tags.length, itemCount: tags.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
@@ -334,9 +347,9 @@ class _AddressBookState extends State<AddressBook> {
return tagBuilder(e); return tagBuilder(e);
}); });
final maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0); final maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return (isDesktop || isWebDesktop) return Obx(() => stateGlobal.isPortrait.isFalse
? gridView ? gridView(false)
: LimitedBox(maxHeight: maxHeight, child: gridView); : LimitedBox(maxHeight: maxHeight, child: gridView(true)));
}); });
} }
@@ -346,7 +359,6 @@ class _AddressBookState extends State<AddressBook> {
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: AddressBookPeersView( child: AddressBookPeersView(
menuPadding: widget.menuPadding, menuPadding: widget.menuPadding,
getInitPeers: () => gFFI.abModel.currentAbPeers,
)), )),
); );
} }
@@ -412,7 +424,8 @@ class _AddressBookState extends State<AddressBook> {
if (canWrite) getEntry(translate("Add ID"), addIdToCurrentAb), if (canWrite) getEntry(translate("Add ID"), addIdToCurrentAb),
if (canWrite) getEntry(translate("Add Tag"), abAddTag), if (canWrite) getEntry(translate("Add Tag"), abAddTag),
getEntry(translate("Unselect all tags"), gFFI.abModel.unsetSelectedTags), getEntry(translate("Unselect all tags"), gFFI.abModel.unsetSelectedTags),
sortMenuItem(), if (gFFI.abModel.legacyMode.value)
sortMenuItem(), // It's already sorted after pulling down
if (canWrite) syncMenuItem(), if (canWrite) syncMenuItem(),
filterMenuItem(), filterMenuItem(),
if (!gFFI.abModel.legacyMode.value && canWrite) if (!gFFI.abModel.legacyMode.value && canWrite)
@@ -495,9 +508,9 @@ class _AddressBookState extends State<AddressBook> {
double marginBottom = 4; double marginBottom = 4;
row({required Widget lable, required Widget input}) { row({required Widget lable, required Widget input}) {
return Row( makeChild(bool isPortrait) => Row(
children: [ children: [
!isMobile !isPortrait
? ConstrainedBox( ? ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100), constraints: const BoxConstraints(minWidth: 100),
child: lable.marginOnly(right: 10)) child: lable.marginOnly(right: 10))
@@ -508,7 +521,8 @@ class _AddressBookState extends State<AddressBook> {
child: input), child: input),
), ),
], ],
).marginOnly(bottom: !isMobile ? 8 : 0); ).marginOnly(bottom: !isPortrait ? 8 : 0);
return Obx(() => makeChild(stateGlobal.isPortrait.isTrue));
} }
return CustomAlertDialog( return CustomAlertDialog(
@@ -531,23 +545,28 @@ class _AddressBookState extends State<AddressBook> {
), ),
], ],
), ),
input: TextField( input: Obx(() => TextField(
controller: idController, controller: idController,
inputFormatters: [IDTextInputFormatter()], inputFormatters: [IDTextInputFormatter()],
decoration: InputDecoration( decoration: InputDecoration(
labelText: !isMobile ? null : translate('ID'), labelText: stateGlobal.isPortrait.isFalse
? null
: translate('ID'),
errorText: errorMsg, errorText: errorMsg,
errorMaxLines: 5), errorMaxLines: 5),
)), ))),
row( row(
lable: Text( lable: Text(
translate('Alias'), translate('Alias'),
style: style, style: style,
), ),
input: TextField( input: Obx(() => TextField(
controller: aliasController, controller: aliasController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: !isMobile ? null : translate('Alias'), labelText: stateGlobal.isPortrait.isFalse
? null
: translate('Alias'),
),
)), )),
), ),
if (isCurrentAbShared) if (isCurrentAbShared)
@@ -556,11 +575,14 @@ class _AddressBookState extends State<AddressBook> {
translate('Password'), translate('Password'),
style: style, style: style,
), ),
input: TextField( input: Obx(
() => TextField(
controller: passwordController, controller: passwordController,
obscureText: !passwordVisible, obscureText: !passwordVisible,
decoration: InputDecoration( decoration: InputDecoration(
labelText: !isMobile ? null : translate('Password'), labelText: stateGlobal.isPortrait.isFalse
? null
: translate('Password'),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
passwordVisible passwordVisible
@@ -574,6 +596,7 @@ class _AddressBookState extends State<AddressBook> {
}, },
), ),
), ),
),
)), )),
if (gFFI.abModel.currentAbTags.isNotEmpty) if (gFFI.abModel.currentAbTags.isNotEmpty)
Align( Align(

View File

@@ -2,22 +2,39 @@ import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/platform_model.dart';
const _kWindowsSystemSound = 'System Sound';
typedef AudioINputSetDevice = void Function(String device); typedef AudioINputSetDevice = void Function(String device);
typedef AudioInputBuilder = Widget Function( typedef AudioInputBuilder = Widget Function(
List<String> devices, String currentDevice, AudioINputSetDevice setDevice); List<String> devices, String currentDevice, AudioINputSetDevice setDevice);
class AudioInput extends StatelessWidget { class AudioInput extends StatelessWidget {
final AudioInputBuilder builder; final AudioInputBuilder builder;
final bool isCm;
final bool isVoiceCall;
const AudioInput({Key? key, required this.builder}) : super(key: key); const AudioInput(
{Key? key,
required this.builder,
required this.isCm,
required this.isVoiceCall})
: super(key: key);
static String getDefault() { static String getDefault() {
if (isWindows) return translate('System Sound'); if (isWindows) return translate('System Sound');
return ''; return '';
} }
static Future<String> getValue() async { static Future<String> getAudioInput(bool isCm, bool isVoiceCall) {
String device = await bind.mainGetOption(key: 'audio-input'); if (isVoiceCall) {
return bind.getVoiceCallInputDevice(isCm: isCm);
} else {
return bind.mainGetOption(key: 'audio-input');
}
}
static Future<String> getValue(bool isCm, bool isVoiceCall) async {
String device = await getAudioInput(isCm, isVoiceCall);
if (device.isNotEmpty) { if (device.isNotEmpty) {
return device; return device;
} else { } else {
@@ -25,31 +42,39 @@ class AudioInput extends StatelessWidget {
} }
} }
static Future<void> setDevice(String device) async { static Future<void> setDevice(
String device, bool isCm, bool isVoiceCall) async {
if (device == getDefault()) device = ''; if (device == getDefault()) device = '';
if (isVoiceCall) {
await bind.setVoiceCallInputDevice(isCm: isCm, device: device);
} else {
await bind.mainSetOption(key: 'audio-input', value: device); await bind.mainSetOption(key: 'audio-input', value: device);
} }
}
static Future<Map<String, Object>> getDevicesInfo() async { static Future<Map<String, Object>> getDevicesInfo(
bool isCm, bool isVoiceCall) async {
List<String> devices = (await bind.mainGetSoundInputs()).toList(); List<String> devices = (await bind.mainGetSoundInputs()).toList();
if (isWindows) { if (isWindows) {
devices.insert(0, translate('System Sound')); devices.insert(0, translate(_kWindowsSystemSound));
} }
String current = await getValue(); String current = await getValue(isCm, isVoiceCall);
return {'devices': devices, 'current': current}; return {'devices': devices, 'current': current};
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return futureBuilder( return futureBuilder(
future: getDevicesInfo(), future: getDevicesInfo(isCm, isVoiceCall),
hasData: (data) { hasData: (data) {
String currentDevice = data['current']; String currentDevice = data['current'];
List<String> devices = data['devices'] as List<String>; List<String> devices = data['devices'] as List<String>;
if (devices.isEmpty) { if (devices.isEmpty) {
return const Offstage(); return const Offstage();
} }
return builder(devices, currentDevice, setDevice); return builder(devices, currentDevice, (devices) {
setDevice(devices, isCm, isVoiceCall);
});
}, },
); );
} }

View File

@@ -189,7 +189,7 @@ class AutocompletePeerTileState extends State<AutocompletePeerTile> {
.map((e) => gFFI.abModel.getCurrentAbTagColor(e)) .map((e) => gFFI.abModel.getCurrentAbTagColor(e))
.toList(); .toList();
return Tooltip( return Tooltip(
message: isMobile message: !(isDesktop || isWebDesktop)
? '' ? ''
: widget.peer.tags.isNotEmpty : widget.peer.tags.isNotEmpty
? '${translate('Tags')}: ${widget.peer.tags.join(', ')}' ? '${translate('Tags')}: ${widget.peer.tags.join(', ')}'

View File

@@ -0,0 +1,38 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
Widget getConnectionPageTitle(BuildContext context, bool isWeb) {
return Row(
children: [
Expanded(
child: Row(
children: [
AutoSizeText(
translate('Control Remote Desktop'),
maxLines: 1,
style: Theme.of(context)
.textTheme
.titleLarge
?.merge(TextStyle(height: 1)),
).marginOnly(right: 4),
Tooltip(
waitDuration: Duration(milliseconds: 300),
message: translate(isWeb ? "web_id_input_tip" : "id_input_tip"),
child: Icon(
Icons.help_outline_outlined,
size: 16,
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.5),
),
),
],
)),
],
);
}

View File

@@ -14,7 +14,11 @@ class UppercaseValidationRule extends ValidationRule {
String get name => translate('uppercase'); String get name => translate('uppercase');
@override @override
bool validate(String value) { bool validate(String value) {
return value.contains(RegExp(r'[A-Z]')); return value.runes.any((int rune) {
var character = String.fromCharCode(rune);
return character.toUpperCase() == character &&
character.toLowerCase() != character;
});
} }
} }
@@ -24,7 +28,11 @@ class LowercaseValidationRule extends ValidationRule {
@override @override
bool validate(String value) { bool validate(String value) {
return value.contains(RegExp(r'[a-z]')); return value.runes.any((int rune) {
var character = String.fromCharCode(rune);
return character.toLowerCase() == character &&
character.toUpperCase() != character;
});
} }
} }

View File

@@ -4,11 +4,13 @@ import 'dart:convert';
import 'package:bot_toast/bot_toast.dart'; import 'package:bot_toast/bot_toast.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
@@ -218,13 +220,15 @@ void changeWhiteList({Function()? callback}) async {
), ),
actions: [ actions: [
dialogButton("Cancel", onPressed: close, isOutline: true), dialogButton("Cancel", onPressed: close, isOutline: true),
if (!isOptFixed)dialogButton("Clear", onPressed: () async { if (!isOptFixed)
dialogButton("Clear", onPressed: () async {
await bind.mainSetOption( await bind.mainSetOption(
key: kOptionWhitelist, value: defaultOptionWhitelist); key: kOptionWhitelist, value: defaultOptionWhitelist);
callback?.call(); callback?.call();
close(); close();
}, isOutline: true), }, isOutline: true),
if (!isOptFixed) dialogButton( if (!isOptFixed)
dialogButton(
"OK", "OK",
onPressed: () async { onPressed: () async {
setState(() { setState(() {
@@ -236,7 +240,8 @@ void changeWhiteList({Function()? callback}) async {
if (newWhiteListField.isEmpty) { if (newWhiteListField.isEmpty) {
// pass // pass
} else { } else {
final ips = newWhiteListField.trim().split(RegExp(r"[\s,;\n]+")); final ips =
newWhiteListField.trim().split(RegExp(r"[\s,;\n]+"));
// test ip // test ip
final ipMatch = RegExp( final ipMatch = RegExp(
r"^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)(\/([1-9]|[1-2][0-9]|3[0-2])){0,1}$"); r"^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)(\/([1-9]|[1-2][0-9]|3[0-2])){0,1}$");
@@ -376,6 +381,7 @@ class DialogTextField extends StatelessWidget {
final FocusNode? focusNode; final FocusNode? focusNode;
final TextInputType? keyboardType; final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final int? maxLength;
static const kUsernameTitle = 'Username'; static const kUsernameTitle = 'Username';
static const kUsernameIcon = Icon(Icons.account_circle_outlined); static const kUsernameIcon = Icon(Icons.account_circle_outlined);
@@ -393,6 +399,7 @@ class DialogTextField extends StatelessWidget {
this.hintText, this.hintText,
this.keyboardType, this.keyboardType,
this.inputFormatters, this.inputFormatters,
this.maxLength,
required this.title, required this.title,
required this.controller}) required this.controller})
: super(key: key); : super(key: key);
@@ -419,6 +426,7 @@ class DialogTextField extends StatelessWidget {
obscureText: obscureText, obscureText: obscureText,
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLength: maxLength,
), ),
), ),
], ],
@@ -675,6 +683,8 @@ class PasswordWidget extends StatefulWidget {
this.reRequestFocus = false, this.reRequestFocus = false,
this.hintText, this.hintText,
this.errorText, this.errorText,
this.title,
this.maxLength,
}) : super(key: key); }) : super(key: key);
final TextEditingController controller; final TextEditingController controller;
@@ -682,6 +692,8 @@ class PasswordWidget extends StatefulWidget {
final bool reRequestFocus; final bool reRequestFocus;
final String? hintText; final String? hintText;
final String? errorText; final String? errorText;
final String? title;
final int? maxLength;
@override @override
State<PasswordWidget> createState() => _PasswordWidgetState(); State<PasswordWidget> createState() => _PasswordWidgetState();
@@ -725,7 +737,7 @@ class _PasswordWidgetState extends State<PasswordWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DialogTextField( return DialogTextField(
title: translate(DialogTextField.kPasswordTitle), title: translate(widget.title ?? DialogTextField.kPasswordTitle),
hintText: translate(widget.hintText ?? 'Enter your password'), hintText: translate(widget.hintText ?? 'Enter your password'),
controller: widget.controller, controller: widget.controller,
prefixIcon: DialogTextField.kPasswordIcon, prefixIcon: DialogTextField.kPasswordIcon,
@@ -744,6 +756,7 @@ class _PasswordWidgetState extends State<PasswordWidget> {
obscureText: !_passwordVisible, obscureText: !_passwordVisible,
errorText: widget.errorText, errorText: widget.errorText,
focusNode: _focusNode, focusNode: _focusNode,
maxLength: widget.maxLength,
); );
} }
} }
@@ -1117,7 +1130,7 @@ void showRequestElevationDialog(
errorText: errPwd.isEmpty ? null : errPwd.value, errorText: errPwd.isEmpty ? null : errPwd.value,
), ),
], ],
).marginOnly(left: (isDesktop || isWebDesktop) ? 35 : 0), ).marginOnly(left: stateGlobal.isPortrait.isFalse ? 35 : 0),
).marginOnly(top: 10), ).marginOnly(top: 10),
], ],
), ),
@@ -1762,9 +1775,70 @@ void renameDialog(
}); });
} }
void changeBot({Function()? callback}) async {
if (bind.mainHasValidBotSync()) {
await bind.mainSetOption(key: "bot", value: "");
callback?.call();
return;
}
String errorText = '';
bool loading = false;
final controller = TextEditingController();
gFFI.dialogManager.show((setState, close, context) {
onVerify() async {
final token = controller.text.trim();
if (token == "") return;
loading = true;
errorText = '';
setState(() {});
final error = await bind.mainVerifyBot(token: token);
if (error == "") {
callback?.call();
close();
} else {
errorText = translate(error);
loading = false;
setState(() {});
}
}
final codeField = TextField(
autofocus: true,
controller: controller,
decoration: InputDecoration(
hintText: translate('Token'),
),
);
return CustomAlertDialog(
title: Text(translate("Telegram bot")),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(translate("enable-bot-desc"),
style: TextStyle(fontSize: 12))
.marginOnly(bottom: 12),
Row(children: [Expanded(child: codeField)]),
if (errorText != '')
Text(errorText, style: TextStyle(color: Colors.red))
.marginOnly(top: 12),
],
),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
loading
? CircularProgressIndicator()
: dialogButton("OK", onPressed: onVerify),
],
onCancel: close,
);
});
}
void change2fa({Function()? callback}) async { void change2fa({Function()? callback}) async {
if (bind.mainHasValid2FaSync()) { if (bind.mainHasValid2FaSync()) {
await bind.mainSetOption(key: "2fa", value: ""); await bind.mainSetOption(key: "2fa", value: "");
await bind.mainClearTrustedDevices();
callback?.call(); callback?.call();
return; return;
} }
@@ -1832,6 +1906,7 @@ void enter2FaDialog(
SessionID sessionId, OverlayDialogManager dialogManager) async { SessionID sessionId, OverlayDialogManager dialogManager) async {
final controller = TextEditingController(); final controller = TextEditingController();
final RxBool submitReady = false.obs; final RxBool submitReady = false.obs;
final RxBool trustThisDevice = false.obs;
dialogManager.dismissAll(); dialogManager.dismissAll();
dialogManager.show((setState, close, context) { dialogManager.show((setState, close, context) {
@@ -1841,7 +1916,7 @@ void enter2FaDialog(
} }
submit() { submit() {
gFFI.send2FA(sessionId, controller.text.trim()); gFFI.send2FA(sessionId, controller.text.trim(), trustThisDevice.value);
close(); close();
dialogManager.showLoading(translate('Logging in...'), dialogManager.showLoading(translate('Logging in...'),
onCancel: closeConnection); onCancel: closeConnection);
@@ -1855,9 +1930,27 @@ void enter2FaDialog(
onChanged: () => submitReady.value = codeField.isReady, onChanged: () => submitReady.value = codeField.isReady,
); );
final trustField = Obx(() => CheckboxListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
title: Text(translate("Trust this device")),
value: trustThisDevice.value,
onChanged: (value) {
if (value == null) return;
trustThisDevice.value = value;
},
));
return CustomAlertDialog( return CustomAlertDialog(
title: Text(translate('enter-2fa-title')), title: Text(translate('enter-2fa-title')),
content: codeField, content: Column(
children: [
codeField,
if (bind.sessionGetEnableTrustedDevices(sessionId: sessionId))
trustField,
],
),
actions: [ actions: [
dialogButton('Cancel', dialogButton('Cancel',
onPressed: cancel, onPressed: cancel,
@@ -2124,3 +2217,283 @@ void setSharedAbPasswordDialog(String abName, Peer peer) {
); );
}); });
} }
void CommonConfirmDialog(OverlayDialogManager dialogManager, String content,
VoidCallback onConfirm) {
dialogManager.show((setState, close, context) {
submit() {
close();
onConfirm.call();
}
return CustomAlertDialog(
content: Row(
children: [
Expanded(
child: Text(content,
style: const TextStyle(fontSize: 15),
textAlign: TextAlign.start),
),
],
).marginOnly(bottom: 12),
actions: [
dialogButton(translate("Cancel"), onPressed: close, isOutline: true),
dialogButton(translate("OK"), onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});
}
void changeUnlockPinDialog(String oldPin, Function() callback) {
final pinController = TextEditingController(text: oldPin);
final confirmController = TextEditingController(text: oldPin);
String? pinErrorText;
String? confirmationErrorText;
final maxLength = bind.mainMaxEncryptLen();
gFFI.dialogManager.show((setState, close, context) {
submit() async {
pinErrorText = null;
confirmationErrorText = null;
final pin = pinController.text.trim();
final confirm = confirmController.text.trim();
if (pin != confirm) {
setState(() {
confirmationErrorText =
translate('The confirmation is not identical.');
});
return;
}
final errorMsg = bind.mainSetUnlockPin(pin: pin);
if (errorMsg != '') {
setState(() {
pinErrorText = translate(errorMsg);
});
return;
}
callback.call();
close();
}
return CustomAlertDialog(
title: Text(translate("Set PIN")),
content: Column(
children: [
DialogTextField(
title: 'PIN',
controller: pinController,
obscureText: true,
errorText: pinErrorText,
maxLength: maxLength,
),
DialogTextField(
title: translate('Confirmation'),
controller: confirmController,
obscureText: true,
errorText: confirmationErrorText,
maxLength: maxLength,
)
],
).marginOnly(bottom: 12),
actions: [
dialogButton(translate("Cancel"), onPressed: close, isOutline: true),
dialogButton(translate("OK"), onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});
}
void checkUnlockPinDialog(String correctPin, Function() passCallback) {
final controller = TextEditingController();
String? errorText;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
final pin = controller.text.trim();
if (correctPin != pin) {
setState(() {
errorText = translate('Wrong PIN');
});
return;
}
passCallback.call();
close();
}
return CustomAlertDialog(
content: Row(
children: [
Expanded(
child: PasswordWidget(
title: 'PIN',
controller: controller,
errorText: errorText,
hintText: '',
))
],
).marginOnly(bottom: 12),
actions: [
dialogButton(translate("Cancel"), onPressed: close, isOutline: true),
dialogButton(translate("OK"), onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});
}
void confrimDeleteTrustedDevicesDialog(
RxList<TrustedDevice> trustedDevices, RxList<Uint8List> selectedDevices) {
CommonConfirmDialog(gFFI.dialogManager, '${translate('Confirm Delete')}?',
() async {
if (selectedDevices.isEmpty) return;
if (selectedDevices.length == trustedDevices.length) {
await bind.mainClearTrustedDevices();
trustedDevices.clear();
selectedDevices.clear();
} else {
final json = jsonEncode(selectedDevices.map((e) => e.toList()).toList());
await bind.mainRemoveTrustedDevices(json: json);
trustedDevices.removeWhere((element) {
return selectedDevices.contains(element.hwid);
});
selectedDevices.clear();
}
});
}
void manageTrustedDeviceDialog() async {
RxList<TrustedDevice> trustedDevices = (await TrustedDevice.get()).obs;
RxList<Uint8List> selectedDevices = RxList.empty();
gFFI.dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Text(translate("Manage trusted devices")),
content: trustedDevicesTable(trustedDevices, selectedDevices),
actions: [
Obx(() => dialogButton(translate("Delete"),
onPressed: selectedDevices.isEmpty
? null
: () {
confrimDeleteTrustedDevicesDialog(
trustedDevices,
selectedDevices,
);
},
isOutline: false)
.marginOnly(top: 12)),
dialogButton(translate("Close"), onPressed: close, isOutline: true)
.marginOnly(top: 12),
],
onCancel: close,
);
});
}
class TrustedDevice {
late final Uint8List hwid;
late final int time;
late final String id;
late final String name;
late final String platform;
TrustedDevice.fromJson(Map<String, dynamic> json) {
final hwidList = json['hwid'] as List<dynamic>;
hwid = Uint8List.fromList(hwidList.cast<int>());
time = json['time'];
id = json['id'];
name = json['name'];
platform = json['platform'];
}
String daysRemaining() {
final expiry = time + 90 * 24 * 60 * 60 * 1000;
final remaining = expiry - DateTime.now().millisecondsSinceEpoch;
if (remaining < 0) {
return '0';
}
return (remaining / (24 * 60 * 60 * 1000)).toStringAsFixed(0);
}
static Future<List<TrustedDevice>> get() async {
final List<TrustedDevice> devices = List.empty(growable: true);
try {
final devicesJson = await bind.mainGetTrustedDevices();
if (devicesJson.isNotEmpty) {
final devicesList = json.decode(devicesJson);
if (devicesList is List) {
for (var device in devicesList) {
devices.add(TrustedDevice.fromJson(device));
}
}
}
} catch (e) {
print(e.toString());
}
devices.sort((a, b) => b.time.compareTo(a.time));
return devices;
}
}
Widget trustedDevicesTable(
RxList<TrustedDevice> devices, RxList<Uint8List> selectedDevices) {
RxBool selectAll = false.obs;
setSelectAll() {
if (selectedDevices.isNotEmpty &&
selectedDevices.length == devices.length) {
selectAll.value = true;
} else {
selectAll.value = false;
}
}
devices.listen((_) {
setSelectAll();
});
selectedDevices.listen((_) {
setSelectAll();
});
return FittedBox(
child: Obx(() => DataTable(
columns: [
DataColumn(
label: Checkbox(
value: selectAll.value,
onChanged: (value) {
if (value == true) {
selectedDevices.clear();
selectedDevices.addAll(devices.map((e) => e.hwid));
} else {
selectedDevices.clear();
}
},
)),
DataColumn(label: Text(translate('Platform'))),
DataColumn(label: Text(translate('ID'))),
DataColumn(label: Text(translate('Username'))),
DataColumn(label: Text(translate('Days remaining'))),
],
rows: devices.map((device) {
return DataRow(cells: [
DataCell(Checkbox(
value: selectedDevices.contains(device.hwid),
onChanged: (value) {
if (value == null) return;
if (value) {
selectedDevices.remove(device.hwid);
selectedDevices.add(device.hwid);
} else {
selectedDevices.remove(device.hwid);
}
},
)),
DataCell(Text(device.platform)),
DataCell(Text(device.id)),
DataCell(Text(device.name)),
DataCell(Text(device.daysRemaining())),
]);
}).toList(),
)),
);
}

View File

@@ -112,6 +112,8 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
}; };
} }
// FIXME: This debounce logic is not working properly.
// If we move our finger very fast, we won't be able to detect the "oneFingerPan" event sometimes.
void onOneFingerStartDebounce(ScaleUpdateDetails d) { void onOneFingerStartDebounce(ScaleUpdateDetails d) {
start(ScaleUpdateDetails d) { start(ScaleUpdateDetails d) {
_currentState = GestureState.oneFingerPan; _currentState = GestureState.oneFingerPan;

View File

@@ -142,11 +142,6 @@ class _WidgetOPState extends State<WidgetOP> {
String _failedMsg = ''; String _failedMsg = '';
String _url = ''; String _url = '';
@override
void initState() {
super.initState();
}
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
@@ -455,7 +450,7 @@ Future<bool?> loginDialog() async {
} }
if (isEmailVerification != null) { if (isEmailVerification != null) {
if (isMobile) { if (isMobile) {
if (close != null) close(false); if (close != null) close(null);
verificationCodeDialog( verificationCodeDialog(
resp.user, resp.secret, isEmailVerification); resp.user, resp.secret, isEmailVerification);
} else { } else {
@@ -712,6 +707,11 @@ Future<bool?> verificationCodeDialog(
dialogButton("Verify", onPressed: getOnSubmit()), dialogButton("Verify", onPressed: getOnSubmit()),
]); ]);
}); });
// For verification code, desktop update other models in login dialog, mobile need to close login dialog first,
// otherwise the soft keyboard will jump out on each key press, so mobile update in verification code dialog.
if (isMobile && res == true) {
await UserModel.updateOtherModels();
}
return res; return res;
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart'; import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/common/widgets/login.dart'; import 'package:flutter_hbb/common/widgets/login.dart';
import 'package:flutter_hbb/common/widgets/peers_view.dart'; import 'package:flutter_hbb/common/widgets/peers_view.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../common.dart'; import '../../common.dart';
@@ -23,11 +24,6 @@ class _MyGroupState extends State<MyGroup> {
RxString get searchUserText => gFFI.groupModel.searchUserText; RxString get searchUserText => gFFI.groupModel.searchUserText;
static TextEditingController searchUserController = TextEditingController(); static TextEditingController searchUserController = TextEditingController();
@override
void initState() {
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
@@ -35,6 +31,8 @@ class _MyGroupState extends State<MyGroup> {
return Center( return Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: loginDialog, child: Text(translate("Login")))); onPressed: loginDialog, child: Text(translate("Login"))));
} else if (gFFI.userModel.networkError.isNotEmpty) {
return netWorkErrorWidget();
} else if (gFFI.groupModel.groupLoading.value && gFFI.groupModel.emtpy) { } else if (gFFI.groupModel.groupLoading.value && gFFI.groupModel.emtpy) {
return const Center( return const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
@@ -48,15 +46,15 @@ class _MyGroupState extends State<MyGroup> {
retry: null, retry: null,
close: () => gFFI.groupModel.groupLoadError.value = ''), close: () => gFFI.groupModel.groupLoadError.value = ''),
Expanded( Expanded(
child: (isDesktop || isWebDesktop) child: Obx(() => stateGlobal.isPortrait.isTrue
? _buildDesktop() ? _buildPortrait()
: _buildMobile()) : _buildLandscape())),
], ],
); );
}); });
} }
Widget _buildDesktop() { Widget _buildLandscape() {
return Row( return Row(
children: [ children: [
Container( Container(
@@ -86,13 +84,13 @@ class _MyGroupState extends State<MyGroup> {
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: MyGroupPeerView( child: MyGroupPeerView(
menuPadding: widget.menuPadding, menuPadding: widget.menuPadding,
getInitPeers: () => gFFI.groupModel.peers)), )),
) )
], ],
); );
} }
Widget _buildMobile() { Widget _buildPortrait() {
return Column( return Column(
children: [ children: [
Container( Container(
@@ -118,7 +116,7 @@ class _MyGroupState extends State<MyGroup> {
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: MyGroupPeerView( child: MyGroupPeerView(
menuPadding: widget.menuPadding, menuPadding: widget.menuPadding,
getInitPeers: () => gFFI.groupModel.peers)), )),
) )
], ],
); );
@@ -162,14 +160,14 @@ class _MyGroupState extends State<MyGroup> {
} }
return true; return true;
}).toList(); }).toList();
final listView = ListView.builder( listView(bool isPortrait) => ListView.builder(
shrinkWrap: isMobile, shrinkWrap: isPortrait,
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) => _buildUserItem(items[index])); itemBuilder: (context, index) => _buildUserItem(items[index]));
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0); var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return (isDesktop || isWebDesktop) return Obx(() => stateGlobal.isPortrait.isFalse
? listView ? listView(false)
: LimitedBox(maxHeight: maxHeight, child: listView); : LimitedBox(maxHeight: maxHeight, child: listView(true)));
}); });
} }

View File

@@ -1,6 +1,8 @@
import 'package:auto_size_text/auto_size_text.dart'; import 'package:auto_size_text/auto_size_text.dart';
import 'package:debounce_throttle/debounce_throttle.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -26,9 +28,12 @@ class DraggableChatWindow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (draggablePositions.chatWindow.isInvalid()) {
draggablePositions.chatWindow.update(position);
}
return isIOS return isIOS
? IOSDraggable( ? IOSDraggable(
position: position, position: draggablePositions.chatWindow,
chatModel: chatModel, chatModel: chatModel,
width: width, width: width,
height: height, height: height,
@@ -45,7 +50,7 @@ class DraggableChatWindow extends StatelessWidget {
) )
: Draggable( : Draggable(
checkKeyboard: true, checkKeyboard: true,
position: SimpleWrapper(position), position: draggablePositions.chatWindow,
width: width, width: width,
height: height, height: height,
chatModel: chatModel, chatModel: chatModel,
@@ -176,7 +181,7 @@ class DraggableMobileActions extends StatelessWidget {
required this.scale}); required this.scale});
final double scale; final double scale;
final SimpleWrapper<Offset> position; final DraggableKeyPosition position;
final double width; final double width;
final double height; final double height;
final VoidCallback? onBackPressed; final VoidCallback? onBackPressed;
@@ -241,6 +246,92 @@ class DraggableMobileActions extends StatelessWidget {
} }
} }
class DraggableKeyPosition {
final String key;
Offset _pos;
late Debouncer<int> _debouncerStore;
DraggableKeyPosition(this.key)
: _pos = DraggablePositions.kInvalidDraggablePosition;
get pos => _pos;
_loadPosition(String k) {
final value = bind.getLocalFlutterOption(k: k);
if (value.isNotEmpty) {
final parts = value.split(',');
if (parts.length == 2) {
return Offset(double.parse(parts[0]), double.parse(parts[1]));
}
}
return DraggablePositions.kInvalidDraggablePosition;
}
load() {
_pos = _loadPosition(key);
_debouncerStore = Debouncer<int>(const Duration(milliseconds: 500),
onChanged: (v) => _store(), initialValue: 0);
}
update(Offset pos) {
_pos = pos;
_triggerStore();
}
// Adjust position to keep it in the screen
// Only used for desktop and web desktop
tryAdjust(double w, double h, double scale) {
final size = MediaQuery.of(Get.context!).size;
w = w * scale;
h = h * scale;
double x = _pos.dx;
double y = _pos.dy;
if (x + w > size.width) {
x = size.width - w;
}
final tabBarHeight = isDesktop ? kDesktopRemoteTabBarHeight : 0;
if (y + h > (size.height - tabBarHeight)) {
y = size.height - tabBarHeight - h;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
if (x != _pos.dx || y != _pos.dy) {
update(Offset(x, y));
}
}
isInvalid() {
return _pos == DraggablePositions.kInvalidDraggablePosition;
}
_triggerStore() => _debouncerStore.value = _debouncerStore.value + 1;
_store() {
bind.setLocalFlutterOption(k: key, v: '${_pos.dx},${_pos.dy}');
}
}
class DraggablePositions {
static const kChatWindow = 'draggablePositionChat';
static const kMobileActions = 'draggablePositionMobile';
static const kIOSDraggable = 'draggablePositionIOS';
static const kInvalidDraggablePosition = Offset(-999999, -999999);
final chatWindow = DraggableKeyPosition(kChatWindow);
final mobileActions = DraggableKeyPosition(kMobileActions);
final iOSDraggable = DraggableKeyPosition(kIOSDraggable);
load() {
chatWindow.load();
mobileActions.load();
iOSDraggable.load();
}
}
DraggablePositions draggablePositions = DraggablePositions();
class Draggable extends StatefulWidget { class Draggable extends StatefulWidget {
Draggable( Draggable(
{Key? key, {Key? key,
@@ -255,14 +346,14 @@ class Draggable extends StatefulWidget {
final bool checkKeyboard; final bool checkKeyboard;
final bool checkScreenSize; final bool checkScreenSize;
final SimpleWrapper<Offset> position; final DraggableKeyPosition position;
final double width; final double width;
final double height; final double height;
final ChatModel? chatModel; final ChatModel? chatModel;
final Widget Function(BuildContext, GestureDragUpdateCallback) builder; final Widget Function(BuildContext, GestureDragUpdateCallback) builder;
@override @override
State<StatefulWidget> createState() => _DraggableState(); State<StatefulWidget> createState() => _DraggableState(chatModel);
} }
class _DraggableState extends State<Draggable> { class _DraggableState extends State<Draggable> {
@@ -271,13 +362,11 @@ class _DraggableState extends State<Draggable> {
double _saveHeight = 0; double _saveHeight = 0;
double _lastBottomHeight = 0; double _lastBottomHeight = 0;
@override _DraggableState(ChatModel? chatModel) {
void initState() { _chatModel = chatModel;
super.initState();
_chatModel = widget.chatModel;
} }
get position => widget.position.value; get position => widget.position.pos;
void onPanUpdate(DragUpdateDetails d) { void onPanUpdate(DragUpdateDetails d) {
final offset = d.delta; final offset = d.delta;
@@ -301,7 +390,7 @@ class _DraggableState extends State<Draggable> {
y = position.dy + offset.dy; y = position.dy + offset.dy;
} }
setState(() { setState(() {
widget.position.value = Offset(x, y); widget.position.update(Offset(x, y));
}); });
_chatModel?.setChatWindowPosition(position); _chatModel?.setChatWindowPosition(position);
} }
@@ -320,7 +409,7 @@ class _DraggableState extends State<Draggable> {
// reset // reset
if (_lastBottomHeight > 0 && bottomHeight == 0) { if (_lastBottomHeight > 0 && bottomHeight == 0) {
setState(() { setState(() {
widget.position.value = Offset(position.dx, _saveHeight); widget.position.update(Offset(position.dx, _saveHeight));
}); });
} }
@@ -331,7 +420,7 @@ class _DraggableState extends State<Draggable> {
if (sumHeight + position.dy > contextHeight) { if (sumHeight + position.dy > contextHeight) {
final y = contextHeight - sumHeight; final y = contextHeight - sumHeight;
setState(() { setState(() {
widget.position.value = Offset(position.dx, y); widget.position.update(Offset(position.dx, y));
}); });
} }
} }
@@ -362,25 +451,25 @@ class _DraggableState extends State<Draggable> {
class IOSDraggable extends StatefulWidget { class IOSDraggable extends StatefulWidget {
const IOSDraggable( const IOSDraggable(
{Key? key, {Key? key,
this.position = Offset.zero,
this.chatModel, this.chatModel,
required this.position,
required this.width, required this.width,
required this.height, required this.height,
required this.builder}) required this.builder})
: super(key: key); : super(key: key);
final Offset position; final DraggableKeyPosition position;
final ChatModel? chatModel; final ChatModel? chatModel;
final double width; final double width;
final double height; final double height;
final Widget Function(BuildContext) builder; final Widget Function(BuildContext) builder;
@override @override
IOSDraggableState createState() => IOSDraggableState(); IOSDraggableState createState() =>
IOSDraggableState(chatModel, width, height);
} }
class IOSDraggableState extends State<IOSDraggable> { class IOSDraggableState extends State<IOSDraggable> {
late Offset _position;
late ChatModel? _chatModel; late ChatModel? _chatModel;
late double _width; late double _width;
late double _height; late double _height;
@@ -388,28 +477,27 @@ class IOSDraggableState extends State<IOSDraggable> {
double _saveHeight = 0; double _saveHeight = 0;
double _lastBottomHeight = 0; double _lastBottomHeight = 0;
@override IOSDraggableState(ChatModel? chatModel, double w, double h) {
void initState() { _chatModel = chatModel;
super.initState(); _width = w;
_position = widget.position; _height = h;
_chatModel = widget.chatModel;
_width = widget.width;
_height = widget.height;
} }
DraggableKeyPosition get position => widget.position;
checkKeyboard() { checkKeyboard() {
final bottomHeight = MediaQuery.of(context).viewInsets.bottom; final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
final currentVisible = bottomHeight != 0; final currentVisible = bottomHeight != 0;
// save // save
if (!_keyboardVisible && currentVisible) { if (!_keyboardVisible && currentVisible) {
_saveHeight = _position.dy; _saveHeight = position.pos.dy;
} }
// reset // reset
if (_lastBottomHeight > 0 && bottomHeight == 0) { if (_lastBottomHeight > 0 && bottomHeight == 0) {
setState(() { setState(() {
_position = Offset(_position.dx, _saveHeight); position.update(Offset(position.pos.dx, _saveHeight));
}); });
} }
@@ -417,10 +505,10 @@ class IOSDraggableState extends State<IOSDraggable> {
if (_keyboardVisible && currentVisible) { if (_keyboardVisible && currentVisible) {
final sumHeight = bottomHeight + _height; final sumHeight = bottomHeight + _height;
final contextHeight = MediaQuery.of(context).size.height; final contextHeight = MediaQuery.of(context).size.height;
if (sumHeight + _position.dy > contextHeight) { if (sumHeight + position.pos.dy > contextHeight) {
final y = contextHeight - sumHeight; final y = contextHeight - sumHeight;
setState(() { setState(() {
_position = Offset(_position.dx, y); position.update(Offset(position.pos.dx, y));
}); });
} }
} }
@@ -435,14 +523,14 @@ class IOSDraggableState extends State<IOSDraggable> {
return Stack( return Stack(
children: [ children: [
Positioned( Positioned(
left: _position.dx, left: position.pos.dx,
top: _position.dy, top: position.pos.dy,
child: GestureDetector( child: GestureDetector(
onPanUpdate: (details) { onPanUpdate: (details) {
setState(() { setState(() {
_position += details.delta; position.update(position.pos + details.delta);
}); });
_chatModel?.setChatWindowPosition(_position); _chatModel?.setChatWindowPosition(position.pos);
}, },
child: Material( child: Material(
child: Container( child: Container(
@@ -498,8 +586,10 @@ class QualityMonitor extends StatelessWidget {
children: [ children: [
_row("Speed", qualityMonitorModel.data.speed ?? '-'), _row("Speed", qualityMonitorModel.data.speed ?? '-'),
_row("FPS", qualityMonitorModel.data.fps ?? '-'), _row("FPS", qualityMonitorModel.data.fps ?? '-'),
// let delay be 0 if fps is 0
_row( _row(
"Delay", "${qualityMonitorModel.data.delay ?? '-'}ms", "Delay",
"${qualityMonitorModel.data.delay == null ? '-' : (qualityMonitorModel.data.fps ?? "").replaceAll(' ', '').replaceAll('0', '').isEmpty ? 0 : qualityMonitorModel.data.delay}ms",
rightColor: Colors.green), rightColor: Colors.green),
_row("Target Bitrate", _row("Target Bitrate",
"${qualityMonitorModel.data.targetBitrate ?? '-'}kb"), "${qualityMonitorModel.data.targetBitrate ?? '-'}kb"),

View File

@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -22,6 +23,8 @@ enum PeerUiType { grid, tile, list }
final peerCardUiType = PeerUiType.grid.obs; final peerCardUiType = PeerUiType.grid.obs;
bool? hideUsernameOnCard;
class _PeerCard extends StatefulWidget { class _PeerCard extends StatefulWidget {
final Peer peer; final Peer peer;
final PeerTabIndex tab; final PeerTabIndex tab;
@@ -51,42 +54,44 @@ class _PeerCardState extends State<_PeerCard>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
if (isDesktop || isWebDesktop) { return Obx(() =>
return _buildDesktop(); stateGlobal.isPortrait.isTrue ? _buildPortrait() : _buildLandscape());
} else {
return _buildMobile();
}
} }
Widget _buildMobile() { Widget gestureDetector({required Widget child}) {
final peer = super.widget.peer;
final PeerTabModel peerTabModel = Provider.of(context); final PeerTabModel peerTabModel = Provider.of(context);
return Card( final peer = super.widget.peer;
margin: EdgeInsets.symmetric(horizontal: 2), return GestureDetector(
child: GestureDetector( onDoubleTap: peerTabModel.multiSelectionMode
? null
: () => widget.connect(context, peer.id),
onTap: () { onTap: () {
if (peerTabModel.multiSelectionMode) { if (peerTabModel.multiSelectionMode) {
peerTabModel.select(peer); peerTabModel.select(peer);
} else { } else {
if (!isWebDesktop) { if (isMobile) {
connectInPeerTab(context, peer, widget.tab); widget.connect(context, peer.id);
} } else {
}
},
onDoubleTap: isWebDesktop
? () => connectInPeerTab(context, peer, widget.tab)
: null,
onLongPress: () {
peerTabModel.select(peer); peerTabModel.select(peer);
}
}
}, },
onLongPress: () => peerTabModel.select(peer),
child: child);
}
Widget _buildPortrait() {
final peer = super.widget.peer;
return Card(
margin: EdgeInsets.symmetric(horizontal: 2),
child: gestureDetector(
child: Container( child: Container(
padding: EdgeInsets.only(left: 12, top: 8, bottom: 8), padding: EdgeInsets.only(left: 12, top: 8, bottom: 8),
child: _buildPeerTile(context, peer, null)), child: _buildPeerTile(context, peer, null)),
)); ));
} }
Widget _buildDesktop() { Widget _buildLandscape() {
final PeerTabModel peerTabModel = Provider.of(context);
final peer = super.widget.peer; final peer = super.widget.peer;
var deco = Rx<BoxDecoration?>( var deco = Rx<BoxDecoration?>(
BoxDecoration( BoxDecoration(
@@ -115,33 +120,27 @@ class _PeerCardState extends State<_PeerCard>
), ),
); );
}, },
child: GestureDetector( child: gestureDetector(
onDoubleTap:
peerTabModel.multiSelectionMode || peerTabModel.isShiftDown
? null
: () => widget.connect(context, peer.id),
onTap: () => peerTabModel.select(peer),
onLongPress: () => peerTabModel.select(peer),
child: Obx(() => peerCardUiType.value == PeerUiType.grid child: Obx(() => peerCardUiType.value == PeerUiType.grid
? _buildPeerCard(context, peer, deco) ? _buildPeerCard(context, peer, deco)
: _buildPeerTile(context, peer, deco))), : _buildPeerTile(context, peer, deco))),
); );
} }
Widget _buildPeerTile( makeChild(bool isPortrait, Peer peer) {
BuildContext context, Peer peer, Rx<BoxDecoration?>? deco) { final name = hideUsernameOnCard == true
final name = ? peer.hostname
'${peer.username}${peer.username.isNotEmpty && peer.hostname.isNotEmpty ? '@' : ''}${peer.hostname}'; : '${peer.username}${peer.username.isNotEmpty && peer.hostname.isNotEmpty ? '@' : ''}${peer.hostname}';
final greyStyle = TextStyle( final greyStyle = TextStyle(
fontSize: 11, fontSize: 11,
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6)); color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
final child = Row( return Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: str2color('${peer.id}${peer.platform}', 0x7f), color: str2color('${peer.id}${peer.platform}', 0x7f),
borderRadius: isMobile borderRadius: isPortrait
? BorderRadius.circular(_tileRadius) ? BorderRadius.circular(_tileRadius)
: BorderRadius.only( : BorderRadius.only(
topLeft: Radius.circular(_tileRadius), topLeft: Radius.circular(_tileRadius),
@@ -149,11 +148,11 @@ class _PeerCardState extends State<_PeerCard>
), ),
), ),
alignment: Alignment.center, alignment: Alignment.center,
width: isMobile ? 50 : 42, width: isPortrait ? 50 : 42,
height: isMobile ? 50 : null, height: isPortrait ? 50 : null,
child: Stack( child: Stack(
children: [ children: [
getPlatformImage(peer.platform, size: isMobile ? 38 : 30) getPlatformImage(peer.platform, size: isPortrait ? 38 : 30)
.paddingAll(6), .paddingAll(6),
if (_shouldBuildPasswordIcon(peer)) if (_shouldBuildPasswordIcon(peer))
Positioned( Positioned(
@@ -178,19 +177,19 @@ class _PeerCardState extends State<_PeerCard>
child: Column( child: Column(
children: [ children: [
Row(children: [ Row(children: [
getOnline(isMobile ? 4 : 8, peer.online), getOnline(isPortrait ? 4 : 8, peer.online),
Expanded( Expanded(
child: Text( child: Text(
peer.alias.isEmpty ? formatID(peer.id) : peer.alias, peer.alias.isEmpty ? formatID(peer.id) : peer.alias,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
)), )),
]).marginOnly(top: isMobile ? 0 : 2), ]).marginOnly(top: isPortrait ? 0 : 2),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
name, name,
style: isMobile ? null : greyStyle, style: isPortrait ? null : greyStyle,
textAlign: TextAlign.start, textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -198,53 +197,65 @@ class _PeerCardState extends State<_PeerCard>
], ],
).marginOnly(top: 2), ).marginOnly(top: 2),
), ),
isMobile isPortrait
? checkBoxOrActionMoreMobile(peer) ? checkBoxOrActionMorePortrait(peer)
: checkBoxOrActionMoreDesktop(peer, isTile: true), : checkBoxOrActionMoreLandscape(peer, isTile: true),
], ],
).paddingOnly(left: 10.0, top: 3.0), ).paddingOnly(left: 10.0, top: 3.0),
), ),
) )
], ],
); );
}
Widget _buildPeerTile(
BuildContext context, Peer peer, Rx<BoxDecoration?>? deco) {
hideUsernameOnCard ??=
bind.mainGetBuildinOption(key: kHideUsernameOnCard) == 'Y';
final colors = _frontN(peer.tags, 25) final colors = _frontN(peer.tags, 25)
.map((e) => gFFI.abModel.getCurrentAbTagColor(e)) .map((e) => gFFI.abModel.getCurrentAbTagColor(e))
.toList(); .toList();
return Tooltip( return Tooltip(
message: isMobile message: !(isDesktop || isWebDesktop)
? '' ? ''
: peer.tags.isNotEmpty : peer.tags.isNotEmpty
? '${translate('Tags')}: ${peer.tags.join(', ')}' ? '${translate('Tags')}: ${peer.tags.join(', ')}'
: '', : '',
child: Stack(children: [ child: Stack(children: [
deco == null Obx(
? child () => deco == null
: Obx( ? makeChild(stateGlobal.isPortrait.isTrue, peer)
() => Container( : Container(
foregroundDecoration: deco.value, foregroundDecoration: deco.value,
child: child, child: makeChild(stateGlobal.isPortrait.isTrue, peer),
), ),
), ),
if (colors.isNotEmpty) if (colors.isNotEmpty)
Positioned( Obx(() => Positioned(
top: 2, top: 2,
right: isMobile ? 20 : 10, right: stateGlobal.isPortrait.isTrue ? 20 : 10,
child: CustomPaint( child: CustomPaint(
painter: TagPainter(radius: 3, colors: colors), painter: TagPainter(radius: 3, colors: colors),
), ),
) ))
]), ]),
); );
} }
Widget _buildPeerCard( Widget _buildPeerCard(
BuildContext context, Peer peer, Rx<BoxDecoration?> deco) { BuildContext context, Peer peer, Rx<BoxDecoration?> deco) {
final name = hideUsernameOnCard ??=
'${peer.username}${peer.username.isNotEmpty && peer.hostname.isNotEmpty ? '@' : ''}${peer.hostname}'; bind.mainGetBuildinOption(key: kHideUsernameOnCard) == 'Y';
final name = hideUsernameOnCard == true
? peer.hostname
: '${peer.username}${peer.username.isNotEmpty && peer.hostname.isNotEmpty ? '@' : ''}${peer.hostname}';
final child = Card( final child = Card(
color: Colors.transparent, color: Colors.transparent,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
// to-do: memory leak here, more investigation needed.
// Continious rebuilds of `Obx()` will cause memory leak here.
// The simple demo does not have this issue.
child: Obx( child: Obx(
() => Container( () => Container(
foregroundDecoration: deco.value, foregroundDecoration: deco.value,
@@ -308,7 +319,7 @@ class _PeerCardState extends State<_PeerCard>
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
)), )),
]).paddingSymmetric(vertical: 8)), ]).paddingSymmetric(vertical: 8)),
checkBoxOrActionMoreDesktop(peer, isTile: false), checkBoxOrActionMoreLandscape(peer, isTile: false),
], ],
).paddingSymmetric(horizontal: 12.0), ).paddingSymmetric(horizontal: 12.0),
) )
@@ -354,7 +365,7 @@ class _PeerCardState extends State<_PeerCard>
} }
} }
Widget checkBoxOrActionMoreMobile(Peer peer) { Widget checkBoxOrActionMorePortrait(Peer peer) {
final PeerTabModel peerTabModel = Provider.of(context); final PeerTabModel peerTabModel = Provider.of(context);
final selected = peerTabModel.isPeerSelected(peer.id); final selected = peerTabModel.isPeerSelected(peer.id);
if (peerTabModel.multiSelectionMode) { if (peerTabModel.multiSelectionMode) {
@@ -382,7 +393,7 @@ class _PeerCardState extends State<_PeerCard>
} }
} }
Widget checkBoxOrActionMoreDesktop(Peer peer, {required bool isTile}) { Widget checkBoxOrActionMoreLandscape(Peer peer, {required bool isTile}) {
final PeerTabModel peerTabModel = Provider.of(context); final PeerTabModel peerTabModel = Provider.of(context);
final selected = peerTabModel.isPeerSelected(peer.id); final selected = peerTabModel.isPeerSelected(peer.id);
if (peerTabModel.multiSelectionMode) { if (peerTabModel.multiSelectionMode) {
@@ -628,8 +639,8 @@ abstract class BasePeerCard extends StatelessWidget {
@protected @protected
Future<bool> _isForceAlwaysRelay(String id) async { Future<bool> _isForceAlwaysRelay(String id) async {
return (await bind.mainGetPeerOption(id: id, key: kOptionForceAlwaysRelay)) return option2bool(kOptionForceAlwaysRelay,
.isNotEmpty; (await bind.mainGetPeerOption(id: id, key: kOptionForceAlwaysRelay)));
} }
@protected @protected
@@ -868,7 +879,7 @@ class RecentPeerCard extends BasePeerCard {
BuildContext context) async { BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [ final List<MenuEntryBase<String>> menuItems = [
_connectAction(context), _connectAction(context),
if (!isWeb) _transferFileAction(context), _transferFileAction(context),
]; ];
final List favs = (await bind.mainGetFav()).toList(); final List favs = (await bind.mainGetFav()).toList();
@@ -887,7 +898,7 @@ class RecentPeerCard extends BasePeerCard {
menuItems.add(_createShortCutAction(peer.id)); menuItems.add(_createShortCutAction(peer.id));
} }
menuItems.add(MenuEntryDivider()); menuItems.add(MenuEntryDivider());
if (isDesktop || isWebDesktop) { if (isMobile || isDesktop || isWebDesktop) {
menuItems.add(_renameAction(peer.id)); menuItems.add(_renameAction(peer.id));
} }
if (await bind.mainPeerHasPassword(id: peer.id)) { if (await bind.mainPeerHasPassword(id: peer.id)) {
@@ -927,7 +938,7 @@ class FavoritePeerCard extends BasePeerCard {
BuildContext context) async { BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [ final List<MenuEntryBase<String>> menuItems = [
_connectAction(context), _connectAction(context),
if (!isWeb) _transferFileAction(context), _transferFileAction(context),
]; ];
if (isDesktop && peer.platform != kPeerPlatformAndroid) { if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context)); menuItems.add(_tcpTunnelingAction(context));
@@ -943,7 +954,7 @@ class FavoritePeerCard extends BasePeerCard {
menuItems.add(_createShortCutAction(peer.id)); menuItems.add(_createShortCutAction(peer.id));
} }
menuItems.add(MenuEntryDivider()); menuItems.add(MenuEntryDivider());
if (isDesktop || isWebDesktop) { if (isMobile || isDesktop || isWebDesktop) {
menuItems.add(_renameAction(peer.id)); menuItems.add(_renameAction(peer.id));
} }
if (await bind.mainPeerHasPassword(id: peer.id)) { if (await bind.mainPeerHasPassword(id: peer.id)) {
@@ -980,7 +991,7 @@ class DiscoveredPeerCard extends BasePeerCard {
BuildContext context) async { BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [ final List<MenuEntryBase<String>> menuItems = [
_connectAction(context), _connectAction(context),
if (!isWeb) _transferFileAction(context), _transferFileAction(context),
]; ];
final List favs = (await bind.mainGetFav()).toList(); final List favs = (await bind.mainGetFav()).toList();
@@ -1033,7 +1044,7 @@ class AddressBookPeerCard extends BasePeerCard {
BuildContext context) async { BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [ final List<MenuEntryBase<String>> menuItems = [
_connectAction(context), _connectAction(context),
if (!isWeb) _transferFileAction(context), _transferFileAction(context),
]; ];
if (isDesktop && peer.platform != kPeerPlatformAndroid) { if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context)); menuItems.add(_tcpTunnelingAction(context));
@@ -1048,7 +1059,7 @@ class AddressBookPeerCard extends BasePeerCard {
} }
if (gFFI.abModel.current.canWrite()) { if (gFFI.abModel.current.canWrite()) {
menuItems.add(MenuEntryDivider()); menuItems.add(MenuEntryDivider());
if (isDesktop || isWebDesktop) { if (isMobile || isDesktop || isWebDesktop) {
menuItems.add(_renameAction(peer.id)); menuItems.add(_renameAction(peer.id));
} }
if (gFFI.abModel.current.isPersonal() && peer.hash.isNotEmpty) { if (gFFI.abModel.current.isPersonal() && peer.hash.isNotEmpty) {
@@ -1165,7 +1176,7 @@ class MyGroupPeerCard extends BasePeerCard {
BuildContext context) async { BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [ final List<MenuEntryBase<String>> menuItems = [
_connectAction(context), _connectAction(context),
if (!isWeb) _transferFileAction(context), _transferFileAction(context),
]; ];
if (isDesktop && peer.platform != kPeerPlatformAndroid) { if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context)); menuItems.add(_tcpTunnelingAction(context));
@@ -1195,6 +1206,7 @@ class MyGroupPeerCard extends BasePeerCard {
} }
void _rdpDialog(String id) async { void _rdpDialog(String id) async {
final maxLength = bind.mainMaxEncryptLen();
final port = await bind.mainGetPeerOption(id: id, key: 'rdp_port'); final port = await bind.mainGetPeerOption(id: id, key: 'rdp_port');
final username = await bind.mainGetPeerOption(id: id, key: 'rdp_username'); final username = await bind.mainGetPeerOption(id: id, key: 'rdp_username');
final portController = TextEditingController(text: port); final portController = TextEditingController(text: port);
@@ -1249,9 +1261,9 @@ void _rdpDialog(String id) async {
), ),
], ],
).marginOnly(bottom: isDesktop ? 8 : 0), ).marginOnly(bottom: isDesktop ? 8 : 0),
Row( Obx(() => Row(
children: [ children: [
(isDesktop || isWebDesktop) stateGlobal.isPortrait.isFalse
? ConstrainedBox( ? ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140), constraints: const BoxConstraints(minWidth: 140),
child: Text( child: Text(
@@ -1262,17 +1274,16 @@ void _rdpDialog(String id) async {
Expanded( Expanded(
child: TextField( child: TextField(
decoration: InputDecoration( decoration: InputDecoration(
labelText: (isDesktop || isWebDesktop) labelText:
? null isDesktop ? null : translate('Username')),
: translate('Username')),
controller: userController, controller: userController,
), ),
), ),
], ],
).marginOnly(bottom: (isDesktop || isWebDesktop) ? 8 : 0), ).marginOnly(bottom: stateGlobal.isPortrait.isFalse ? 8 : 0)),
Row( Obx(() => Row(
children: [ children: [
(isDesktop || isWebDesktop) stateGlobal.isPortrait.isFalse
? ConstrainedBox( ? ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140), constraints: const BoxConstraints(minWidth: 140),
child: Text( child: Text(
@@ -1283,12 +1294,13 @@ void _rdpDialog(String id) async {
Expanded( Expanded(
child: Obx(() => TextField( child: Obx(() => TextField(
obscureText: secure.value, obscureText: secure.value,
maxLength: maxLength,
decoration: InputDecoration( decoration: InputDecoration(
labelText: (isDesktop || isWebDesktop) labelText:
? null isDesktop ? null : translate('Password'),
: translate('Password'),
suffixIcon: IconButton( suffixIcon: IconButton(
onPressed: () => secure.value = !secure.value, onPressed: () =>
secure.value = !secure.value,
icon: Icon(secure.value icon: Icon(secure.value
? Icons.visibility_off ? Icons.visibility_off
: Icons.visibility))), : Icons.visibility))),
@@ -1296,7 +1308,7 @@ void _rdpDialog(String id) async {
)), )),
), ),
], ],
) ))
], ],
), ),
), ),

View File

@@ -16,6 +16,7 @@ import 'package:flutter_hbb/models/ab_model.dart';
import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -76,8 +77,11 @@ class _PeerTabPageState extends State<PeerTabPage>
final isOptVisiableFixed = isOptionFixed(kOptionPeerTabVisible); final isOptVisiableFixed = isOptionFixed(kOptionPeerTabVisible);
@override _PeerTabPageState() {
void initState() { _loadLocalOptions();
}
void _loadLocalOptions() {
final uiType = bind.getLocalFlutterOption(k: kOptionPeerCardUiType); final uiType = bind.getLocalFlutterOption(k: kOptionPeerCardUiType);
if (uiType != '') { if (uiType != '') {
peerCardUiType.value = int.parse(uiType) == 0 peerCardUiType.value = int.parse(uiType) == 0
@@ -87,8 +91,7 @@ class _PeerTabPageState extends State<PeerTabPage>
: PeerUiType.list; : PeerUiType.list;
} }
hideAbTagsPanel.value = hideAbTagsPanel.value =
bind.mainGetLocalOption(key: kOptionHideAbTagsPanel).isNotEmpty; bind.mainGetLocalOption(key: kOptionHideAbTagsPanel) == 'Y';
super.initState();
} }
Future<void> handleTabSelection(int tabIndex) async { Future<void> handleTabSelection(int tabIndex) async {
@@ -105,33 +108,33 @@ class _PeerTabPageState extends State<PeerTabPage>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final model = Provider.of<PeerTabModel>(context); final model = Provider.of<PeerTabModel>(context);
Widget selectionWrap(Widget widget) { Widget selectionWrap(Widget widget) {
return model.multiSelectionMode ? createMultiSelectionBar() : widget; return model.multiSelectionMode ? createMultiSelectionBar(model) : widget;
} }
return Column( return Column(
textBaseline: TextBaseline.ideographic, textBaseline: TextBaseline.ideographic,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( Obx(() => SizedBox(
height: 32, height: 32,
child: Container( child: Container(
padding: (isDesktop || isWebDesktop) padding: stateGlobal.isPortrait.isTrue
? null ? EdgeInsets.symmetric(horizontal: 2)
: EdgeInsets.symmetric(horizontal: 2), : null,
child: selectionWrap(Row( child: selectionWrap(Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: child: visibleContextMenuListener(
visibleContextMenuListener(_createSwitchBar(context))), _createSwitchBar(context))),
if (isMobile) if (stateGlobal.isPortrait.isTrue)
..._mobileRightActions(context) ..._portraitRightActions(context)
else else
..._desktopRightActions(context) ..._landscapeRightActions(context)
], ],
)), )),
), ),
).paddingOnly(right: (isDesktop || isWebDesktop) ? 12 : 0), ).paddingOnly(right: stateGlobal.isPortrait.isTrue ? 0 : 12)),
_createPeersView(), _createPeersView(),
], ],
); );
@@ -297,7 +300,7 @@ class _PeerTabPageState extends State<PeerTabPage>
} }
Widget visibleContextMenuListener(Widget child) { Widget visibleContextMenuListener(Widget child) {
if (isMobile) { if (!(isDesktop || isWebDesktop)) {
return GestureDetector( return GestureDetector(
onLongPressDown: (e) { onLongPressDown: (e) {
final x = e.globalPosition.dx; final x = e.globalPosition.dx;
@@ -359,8 +362,7 @@ class _PeerTabPageState extends State<PeerTabPage>
.toList()); .toList());
} }
Widget createMultiSelectionBar() { Widget createMultiSelectionBar(PeerTabModel model) {
final model = Provider.of<PeerTabModel>(context);
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -378,7 +380,7 @@ class _PeerTabPageState extends State<PeerTabPage>
Row( Row(
children: [ children: [
selectionCount(model.selectedPeers.length), selectionCount(model.selectedPeers.length),
selectAll(), selectAll(model),
closeSelection(), closeSelection(),
], ],
) )
@@ -399,9 +401,9 @@ class _PeerTabPageState extends State<PeerTabPage>
final peers = model.selectedPeers; final peers = model.selectedPeers;
switch (model.currentTab) { switch (model.currentTab) {
case 0: case 0:
peers.map((p) async { for (var p in peers) {
await bind.mainRemovePeer(id: p.id); await bind.mainRemovePeer(id: p.id);
}).toList(); }
await bind.mainLoadRecentPeers(); await bind.mainLoadRecentPeers();
break; break;
case 1: case 1:
@@ -413,9 +415,9 @@ class _PeerTabPageState extends State<PeerTabPage>
await bind.mainLoadFavPeers(); await bind.mainLoadFavPeers();
break; break;
case 2: case 2:
peers.map((p) async { for (var p in peers) {
await bind.mainRemoveDiscovered(id: p.id); await bind.mainRemoveDiscovered(id: p.id);
}).toList(); }
await bind.mainLoadLanPeers(); await bind.mainLoadLanPeers();
break; break;
case 3: case 3:
@@ -454,7 +456,7 @@ class _PeerTabPageState extends State<PeerTabPage>
showToast(translate('Successful')); showToast(translate('Successful'));
}, },
child: Icon(PeerTabModel.icons[PeerTabIndex.fav.index]), child: Icon(PeerTabModel.icons[PeerTabIndex.fav.index]),
).marginOnly(left: isMobile ? 11 : 6), ).marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
); );
} }
@@ -475,7 +477,7 @@ class _PeerTabPageState extends State<PeerTabPage>
model.setMultiSelectionMode(false); model.setMultiSelectionMode(false);
}, },
child: Icon(PeerTabModel.icons[PeerTabIndex.ab.index]), child: Icon(PeerTabModel.icons[PeerTabIndex.ab.index]),
).marginOnly(left: isMobile ? 11 : 6), ).marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
); );
} }
@@ -498,7 +500,7 @@ class _PeerTabPageState extends State<PeerTabPage>
}); });
}, },
child: Icon(Icons.tag)) child: Icon(Icons.tag))
.marginOnly(left: isMobile ? 11 : 6), .marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
); );
} }
@@ -509,8 +511,7 @@ class _PeerTabPageState extends State<PeerTabPage>
); );
} }
Widget selectAll() { Widget selectAll(PeerTabModel model) {
final model = Provider.of<PeerTabModel>(context);
return Offstage( return Offstage(
offstage: offstage:
model.selectedPeers.length >= model.currentTabCachedPeers.length, model.selectedPeers.length >= model.currentTabCachedPeers.length,
@@ -554,10 +555,10 @@ class _PeerTabPageState extends State<PeerTabPage>
}); });
} }
List<Widget> _desktopRightActions(BuildContext context) { List<Widget> _landscapeRightActions(BuildContext context) {
final model = Provider.of<PeerTabModel>(context); final model = Provider.of<PeerTabModel>(context);
return [ return [
const PeerSearchBar().marginOnly(right: isMobile ? 0 : 13), const PeerSearchBar().marginOnly(right: 13),
_createRefresh( _createRefresh(
index: PeerTabIndex.ab, loading: gFFI.abModel.currentAbLoading), index: PeerTabIndex.ab, loading: gFFI.abModel.currentAbLoading),
_createRefresh( _createRefresh(
@@ -578,7 +579,7 @@ class _PeerTabPageState extends State<PeerTabPage>
]; ];
} }
List<Widget> _mobileRightActions(BuildContext context) { List<Widget> _portraitRightActions(BuildContext context) {
final model = Provider.of<PeerTabModel>(context); final model = Provider.of<PeerTabModel>(context);
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final leftIconSize = Theme.of(context).iconTheme.size ?? 24; final leftIconSize = Theme.of(context).iconTheme.size ?? 24;
@@ -699,13 +700,13 @@ class _PeerSearchBarState extends State<PeerSearchBar> {
baseOffset: 0, baseOffset: 0,
extentOffset: peerSearchTextController.value.text.length); extentOffset: peerSearchTextController.value.text.length);
}); });
return Container( return Obx(() => Container(
width: isMobile ? 120 : 140, width: stateGlobal.isPortrait.isTrue ? 120 : 140,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background, color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Obx(() => Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Row( child: Row(
@@ -766,8 +767,8 @@ class _PeerSearchBarState extends State<PeerSearchBar> {
), ),
) )
], ],
)), ),
); ));
} }
} }
@@ -802,13 +803,20 @@ class _PeerViewDropdownState extends State<PeerViewDropdown> {
child: SizedBox( child: SizedBox(
height: 36, height: 36,
child: getRadio<PeerUiType>( child: getRadio<PeerUiType>(
Text( Tooltip(
translate(types.indexOf(e) == 0 message: translate(types.indexOf(e) == 0
? 'Big tiles' ? 'Big tiles'
: types.indexOf(e) == 1 : types.indexOf(e) == 1
? 'Small tiles' ? 'Small tiles'
: 'List'), : 'List'),
style: style), child: Icon(
e == PeerUiType.grid
? Icons.grid_view_rounded
: e == PeerUiType.list
? Icons.view_list_rounded
: Icons.view_agenda_rounded,
size: 18,
)),
e, e,
peerCardUiType.value, peerCardUiType.value,
dense: true, dense: true,
@@ -838,7 +846,7 @@ class _PeerViewDropdownState extends State<PeerViewDropdown> {
child: Icon( child: Icon(
peerCardUiType.value == PeerUiType.grid peerCardUiType.value == PeerUiType.grid
? Icons.grid_view_rounded ? Icons.grid_view_rounded
: peerCardUiType.value == PeerUiType.tile : peerCardUiType.value == PeerUiType.list
? Icons.view_list_rounded ? Icons.view_list_rounded
: Icons.view_agenda_rounded, : Icons.view_agenda_rounded,
size: 18, size: 18,
@@ -865,17 +873,19 @@ class PeerSortDropdown extends StatefulWidget {
} }
class _PeerSortDropdownState extends State<PeerSortDropdown> { class _PeerSortDropdownState extends State<PeerSortDropdown> {
@override _PeerSortDropdownState() {
void initState() {
if (!PeerSortType.values.contains(peerSort.value)) { if (!PeerSortType.values.contains(peerSort.value)) {
_loadLocalOptions();
}
}
void _loadLocalOptions() {
peerSort.value = PeerSortType.remoteId; peerSort.value = PeerSortType.remoteId;
bind.setLocalFlutterOption( bind.setLocalFlutterOption(
k: kOptionPeerSorting, k: kOptionPeerSorting,
v: peerSort.value, v: peerSort.value,
); );
} }
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/scroll_wrapper.dart'; import 'package:flutter_hbb/desktop/widgets/scroll_wrapper.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:visibility_detector/visibility_detector.dart'; import 'package:visibility_detector/visibility_detector.dart';
@@ -41,14 +43,26 @@ class LoadEvent {
static const String group = 'load_group_peers'; static const String group = 'load_group_peers';
} }
class PeersModelName {
static const String recent = 'recent peer';
static const String favorite = 'fav peer';
static const String lan = 'discovered peer';
static const String addressBook = 'address book peer';
static const String group = 'group peer';
}
/// for peer search text, global obs value /// for peer search text, global obs value
final peerSearchText = "".obs; final peerSearchText = "".obs;
/// for peer sort, global obs value /// for peer sort, global obs value
final peerSort = bind.getLocalFlutterOption(k: kOptionPeerSorting).obs; RxString? _peerSort;
RxString get peerSort {
_peerSort ??= bind.getLocalFlutterOption(k: kOptionPeerSorting).obs;
return _peerSort!;
}
// list for listener // list for listener
final obslist = [peerSearchText, peerSort].obs; RxList<RxString> get obslist => [peerSearchText, peerSort].obs;
final peerSearchTextController = final peerSearchTextController =
TextEditingController(text: peerSearchText.value); TextEditingController(text: peerSearchText.value);
@@ -70,7 +84,8 @@ class _PeersView extends StatefulWidget {
} }
/// State for the peer widget. /// State for the peer widget.
class _PeersViewState extends State<_PeersView> with WindowListener { class _PeersViewState extends State<_PeersView>
with WindowListener, WidgetsBindingObserver {
static const int _maxQueryCount = 3; static const int _maxQueryCount = 3;
final HashMap<String, String> _emptyMessages = HashMap.from({ final HashMap<String, String> _emptyMessages = HashMap.from({
LoadEvent.recent: 'empty_recent_tip', LoadEvent.recent: 'empty_recent_tip',
@@ -82,9 +97,11 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
final _curPeers = <String>{}; final _curPeers = <String>{};
var _lastChangeTime = DateTime.now(); var _lastChangeTime = DateTime.now();
var _lastQueryPeers = <String>{}; var _lastQueryPeers = <String>{};
var _lastQueryTime = DateTime.now().add(const Duration(seconds: 30)); var _lastQueryTime = DateTime.now();
var _lastWindowRestoreTime = DateTime.now();
var _queryCount = 0; var _queryCount = 0;
var _exit = false; var _exit = false;
bool _isActive = true;
final _scrollController = ScrollController(); final _scrollController = ScrollController();
@@ -95,12 +112,14 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
@override @override
void initState() { void initState() {
windowManager.addListener(this); windowManager.addListener(this);
WidgetsBinding.instance.addObserver(this);
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
windowManager.removeListener(this); windowManager.removeListener(this);
WidgetsBinding.instance.removeObserver(this);
_exit = true; _exit = true;
super.dispose(); super.dispose();
} }
@@ -108,17 +127,61 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
@override @override
void onWindowFocus() { void onWindowFocus() {
_queryCount = 0; _queryCount = 0;
_isActive = true;
}
@override
void onWindowBlur() {
// We need this comparison because window restore (on Windows) also triggers `onWindowBlur()`.
// Maybe it's a bug of the window manager, but the source code seems to be correct.
//
// Although `onWindowRestore()` is called after `onWindowBlur()` in my test,
// we need the following comparison to ensure that `_isActive` is true in the end.
if (isWindows &&
DateTime.now().difference(_lastWindowRestoreTime) <
const Duration(milliseconds: 300)) {
return;
}
_queryCount = _maxQueryCount;
_isActive = false;
}
@override
void onWindowRestore() {
// Window restore (on MacOS and Linux) also triggers `onWindowFocus()`.
// But on Windows, it triggers `onWindowBlur()`, mybe it's a bug of the window manager.
if (!isWindows) return;
_queryCount = 0;
_isActive = true;
_lastWindowRestoreTime = DateTime.now();
} }
@override @override
void onWindowMinimize() { void onWindowMinimize() {
_queryCount = _maxQueryCount; // Window minimize also triggers `onWindowBlur()`.
}
// This function is required for mobile.
// `onWindowFocus` works fine for desktop.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (isDesktop || isWebDesktop) return;
if (state == AppLifecycleState.resumed) {
_isActive = true;
_queryCount = 0;
} else if (state == AppLifecycleState.inactive) {
_isActive = false;
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProvider<Peers>( // We should avoid too many rebuilds. MacOS(m1, 14.6.1) on Flutter 3.19.6.
create: (context) => widget.peers, // Continious rebuilds of `ChangeNotifierProvider` will cause memory leak.
// Simple demo can reproduce this issue.
return ChangeNotifierProvider<Peers>.value(
value: widget.peers,
child: Consumer<Peers>(builder: (context, peers, child) { child: Consumer<Peers>(builder: (context, peers, child) {
if (peers.peers.isEmpty) { if (peers.peers.isEmpty) {
gFFI.peerTabModel.setCurrentTabCachedPeers([]); gFFI.peerTabModel.setCurrentTabCachedPeers([]);
@@ -172,7 +235,7 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
var peers = snapshot.data!; var peers = snapshot.data!;
if (peers.length > 1000) peers = peers.sublist(0, 1000); if (peers.length > 1000) peers = peers.sublist(0, 1000);
gFFI.peerTabModel.setCurrentTabCachedPeers(peers); gFFI.peerTabModel.setCurrentTabCachedPeers(peers);
buildOnePeer(Peer peer) { buildOnePeer(Peer peer, bool isPortrait) {
final visibilityChild = VisibilityDetector( final visibilityChild = VisibilityDetector(
key: ValueKey(_cardId(peer.id)), key: ValueKey(_cardId(peer.id)),
onVisibilityChanged: onVisibilityChanged, onVisibilityChanged: onVisibilityChanged,
@@ -184,7 +247,7 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
// No need to listen the currentTab change event. // No need to listen the currentTab change event.
// Because the currentTab change event will trigger the peers change event, // Because the currentTab change event will trigger the peers change event,
// and the peers change event will trigger _buildPeersView(). // and the peers change event will trigger _buildPeersView().
return (isDesktop || isWebDesktop) return !isPortrait
? Obx(() => peerCardUiType.value == PeerUiType.list ? Obx(() => peerCardUiType.value == PeerUiType.list
? Container(height: 45, child: visibilityChild) ? Container(height: 45, child: visibilityChild)
: peerCardUiType.value == PeerUiType.grid : peerCardUiType.value == PeerUiType.grid
@@ -195,17 +258,18 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
: Container(child: visibilityChild); : Container(child: visibilityChild);
} }
final Widget child; // We should avoid too many rebuilds. Win10(Some machines) on Flutter 3.19.6.
if (isMobile) { // Continious rebuilds of `ListView.builder` will cause memory leak.
child = ListView.builder( // Simple demo can reproduce this issue.
final Widget child = Obx(() => stateGlobal.isPortrait.isTrue
? ListView.builder(
itemCount: peers.length, itemCount: peers.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index]).marginOnly( return buildOnePeer(peers[index], true).marginOnly(
top: index == 0 ? 0 : space / 2, bottom: space / 2); top: index == 0 ? 0 : space / 2, bottom: space / 2);
}, },
); )
} else { : peerCardUiType.value == PeerUiType.list
child = Obx(() => peerCardUiType.value == PeerUiType.list
? DesktopScrollWrapper( ? DesktopScrollWrapper(
scrollController: _scrollController, scrollController: _scrollController,
child: ListView.builder( child: ListView.builder(
@@ -213,7 +277,8 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
physics: DraggableNeverScrollableScrollPhysics(), physics: DraggableNeverScrollableScrollPhysics(),
itemCount: peers.length, itemCount: peers.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index]).marginOnly( return buildOnePeer(peers[index], false)
.marginOnly(
right: space, right: space,
top: index == 0 ? 0 : space / 2, top: index == 0 ? 0 : space / 2,
bottom: space / 2); bottom: space / 2);
@@ -229,10 +294,9 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
crossAxisSpacing: space), crossAxisSpacing: space),
itemCount: peers.length, itemCount: peers.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index]); return buildOnePeer(peers[index], false);
}), }),
)); ));
}
if (updateEvent == UpdateEvent.load) { if (updateEvent == UpdateEvent.load) {
_curPeers.clear(); _curPeers.clear();
@@ -253,10 +317,14 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
return body; return body;
} }
final _queryInterval = const Duration(seconds: 20); var _queryInterval = const Duration(seconds: 20);
void _startCheckOnlines() { void _startCheckOnlines() {
() async { () async {
final p = await bind.mainIsUsingPublicServer();
if (!p) {
_queryInterval = const Duration(seconds: 6);
}
while (!_exit) { while (!_exit) {
final now = DateTime.now(); final now = DateTime.now();
if (!setEquals(_curPeers, _lastQueryPeers)) { if (!setEquals(_curPeers, _lastQueryPeers)) {
@@ -264,7 +332,12 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
_queryOnlines(false); _queryOnlines(false);
} }
} else { } else {
if (_queryCount < _maxQueryCount) { final skipIfIsWeb =
isWeb && !(stateGlobal.isWebVisible && stateGlobal.isInMainPage);
final skipIfMobile =
(isAndroid || isIOS) && !stateGlobal.isInMainPage;
final skipIfNotActive = skipIfIsWeb || skipIfMobile || !_isActive;
if (!skipIfNotActive && (_queryCount < _maxQueryCount || !p)) {
if (now.difference(_lastQueryTime) >= _queryInterval) { if (now.difference(_lastQueryTime) >= _queryInterval) {
if (_curPeers.isNotEmpty) { if (_curPeers.isNotEmpty) {
bind.queryOnlines(ids: _curPeers.toList(growable: false)); bind.queryOnlines(ids: _curPeers.toList(growable: false));
@@ -282,14 +355,14 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
_queryOnlines(bool isLoadEvent) { _queryOnlines(bool isLoadEvent) {
if (_curPeers.isNotEmpty) { if (_curPeers.isNotEmpty) {
bind.queryOnlines(ids: _curPeers.toList(growable: false)); bind.queryOnlines(ids: _curPeers.toList(growable: false));
_queryCount = 0;
}
_lastQueryPeers = {..._curPeers}; _lastQueryPeers = {..._curPeers};
if (isLoadEvent) { if (isLoadEvent) {
_lastChangeTime = DateTime.now(); _lastChangeTime = DateTime.now();
} else { } else {
_lastQueryTime = DateTime.now().subtract(_queryInterval); _lastQueryTime = DateTime.now().subtract(_queryInterval);
} }
_queryCount = 0;
}
} }
Future<List<Peer>>? matchPeers( Future<List<Peer>>? matchPeers(
@@ -345,28 +418,39 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
} }
abstract class BasePeersView extends StatelessWidget { abstract class BasePeersView extends StatelessWidget {
final String name; final PeerTabIndex peerTabIndex;
final String loadEvent;
final PeerFilter? peerFilter; final PeerFilter? peerFilter;
final PeerCardBuilder peerCardBuilder; final PeerCardBuilder peerCardBuilder;
final GetInitPeers? getInitPeers;
const BasePeersView({ const BasePeersView({
Key? key, Key? key,
required this.name, required this.peerTabIndex,
required this.loadEvent,
this.peerFilter, this.peerFilter,
required this.peerCardBuilder, required this.peerCardBuilder,
required this.getInitPeers,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Peers peers;
switch (peerTabIndex) {
case PeerTabIndex.recent:
peers = gFFI.recentPeersModel;
break;
case PeerTabIndex.fav:
peers = gFFI.favoritePeersModel;
break;
case PeerTabIndex.lan:
peers = gFFI.lanPeersModel;
break;
case PeerTabIndex.ab:
peers = gFFI.abModel.peersModel;
break;
case PeerTabIndex.group:
peers = gFFI.groupModel.peersModel;
break;
}
return _PeersView( return _PeersView(
peers: peers: peers, peerFilter: peerFilter, peerCardBuilder: peerCardBuilder);
Peers(name: name, loadEvent: loadEvent, getInitPeers: getInitPeers),
peerFilter: peerFilter,
peerCardBuilder: peerCardBuilder);
} }
} }
@@ -375,13 +459,11 @@ class RecentPeersView extends BasePeersView {
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController}) {Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super( : super(
key: key, key: key,
name: 'recent peer', peerTabIndex: PeerTabIndex.recent,
loadEvent: LoadEvent.recent,
peerCardBuilder: (Peer peer) => RecentPeerCard( peerCardBuilder: (Peer peer) => RecentPeerCard(
peer: peer, peer: peer,
menuPadding: menuPadding, menuPadding: menuPadding,
), ),
getInitPeers: null,
); );
@override @override
@@ -397,13 +479,11 @@ class FavoritePeersView extends BasePeersView {
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController}) {Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super( : super(
key: key, key: key,
name: 'favorite peer', peerTabIndex: PeerTabIndex.fav,
loadEvent: LoadEvent.favorite,
peerCardBuilder: (Peer peer) => FavoritePeerCard( peerCardBuilder: (Peer peer) => FavoritePeerCard(
peer: peer, peer: peer,
menuPadding: menuPadding, menuPadding: menuPadding,
), ),
getInitPeers: null,
); );
@override @override
@@ -419,13 +499,11 @@ class DiscoveredPeersView extends BasePeersView {
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController}) {Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super( : super(
key: key, key: key,
name: 'discovered peer', peerTabIndex: PeerTabIndex.lan,
loadEvent: LoadEvent.lan,
peerCardBuilder: (Peer peer) => DiscoveredPeerCard( peerCardBuilder: (Peer peer) => DiscoveredPeerCard(
peer: peer, peer: peer,
menuPadding: menuPadding, menuPadding: menuPadding,
), ),
getInitPeers: null,
); );
@override @override
@@ -438,21 +516,16 @@ class DiscoveredPeersView extends BasePeersView {
class AddressBookPeersView extends BasePeersView { class AddressBookPeersView extends BasePeersView {
AddressBookPeersView( AddressBookPeersView(
{Key? key, {Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
EdgeInsets? menuPadding,
ScrollController? scrollController,
required GetInitPeers getInitPeers})
: super( : super(
key: key, key: key,
name: 'address book peer', peerTabIndex: PeerTabIndex.ab,
loadEvent: LoadEvent.addressBook,
peerFilter: (Peer peer) => peerFilter: (Peer peer) =>
_hitTag(gFFI.abModel.selectedTags, peer.tags), _hitTag(gFFI.abModel.selectedTags, peer.tags),
peerCardBuilder: (Peer peer) => AddressBookPeerCard( peerCardBuilder: (Peer peer) => AddressBookPeerCard(
peer: peer, peer: peer,
menuPadding: menuPadding, menuPadding: menuPadding,
), ),
getInitPeers: getInitPeers,
); );
static bool _hitTag(List<dynamic> selectedTags, List<dynamic> idents) { static bool _hitTag(List<dynamic> selectedTags, List<dynamic> idents) {
@@ -479,20 +552,15 @@ class AddressBookPeersView extends BasePeersView {
class MyGroupPeerView extends BasePeersView { class MyGroupPeerView extends BasePeersView {
MyGroupPeerView( MyGroupPeerView(
{Key? key, {Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
EdgeInsets? menuPadding,
ScrollController? scrollController,
required GetInitPeers getInitPeers})
: super( : super(
key: key, key: key,
name: 'group peer', peerTabIndex: PeerTabIndex.group,
loadEvent: LoadEvent.group,
peerFilter: filter, peerFilter: filter,
peerCardBuilder: (Peer peer) => MyGroupPeerCard( peerCardBuilder: (Peer peer) => MyGroupPeerCard(
peer: peer, peer: peer,
menuPadding: menuPadding, menuPadding: menuPadding,
), ),
getInitPeers: getInitPeers,
); );
static bool filter(Peer peer) { static bool filter(Peer peer) {

View File

@@ -27,6 +27,10 @@ class RawKeyFocusScope extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// https://github.com/flutter/flutter/issues/154053
final useRawKeyEvents = isLinux && !isWeb;
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
// while `Alt` and `Control` are seperated key events for en-US input method.
return FocusScope( return FocusScope(
autofocus: true, autofocus: true,
child: Focus( child: Focus(
@@ -34,8 +38,14 @@ class RawKeyFocusScope extends StatelessWidget {
canRequestFocus: true, canRequestFocus: true,
focusNode: focusNode, focusNode: focusNode,
onFocusChange: onFocusChange, onFocusChange: onFocusChange,
onKey: (FocusNode data, RawKeyEvent e) => onKey: useRawKeyEvents
inputModel.handleRawKeyEvent(e), ? (FocusNode data, RawKeyEvent event) =>
inputModel.handleRawKeyEvent(event)
: null,
onKeyEvent: useRawKeyEvents
? null
: (FocusNode node, KeyEvent event) =>
inputModel.handleKeyEvent(event),
child: child)); child: child));
} }
} }
@@ -69,6 +79,8 @@ class RawTouchGestureDetectorRegion extends StatefulWidget {
class _RawTouchGestureDetectorRegionState class _RawTouchGestureDetectorRegionState
extends State<RawTouchGestureDetectorRegion> { extends State<RawTouchGestureDetectorRegion> {
Offset _cacheLongPressPosition = Offset(0, 0); Offset _cacheLongPressPosition = Offset(0, 0);
// Timestamp of the last long press event.
int _cacheLongPressPositionTs = 0;
double _mouseScrollIntegral = 0; // mouse scroll speed controller double _mouseScrollIntegral = 0; // mouse scroll speed controller
double _scale = 1; double _scale = 1;
@@ -95,20 +107,22 @@ class _RawTouchGestureDetectorRegionState
} }
if (handleTouch) { if (handleTouch) {
// Desktop or mobile "Touch mode" // Desktop or mobile "Touch mode"
ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); if (ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy)) {
inputModel.tapDown(MouseButtons.left); inputModel.tapDown(MouseButtons.left);
} }
} }
}
onTapUp(TapUpDetails d) { onTapUp(TapUpDetails d) {
if (lastDeviceKind != PointerDeviceKind.touch) { if (lastDeviceKind != PointerDeviceKind.touch) {
return; return;
} }
if (handleTouch) { if (handleTouch) {
ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); if (ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy)) {
inputModel.tapUp(MouseButtons.left); inputModel.tapUp(MouseButtons.left);
} }
} }
}
onTap() { onTap() {
if (lastDeviceKind != PointerDeviceKind.touch) { if (lastDeviceKind != PointerDeviceKind.touch) {
@@ -134,6 +148,9 @@ class _RawTouchGestureDetectorRegionState
if (lastDeviceKind != PointerDeviceKind.touch) { if (lastDeviceKind != PointerDeviceKind.touch) {
return; return;
} }
if (ffiModel.touchMode && ffi.cursorModel.lastIsBlocked) {
return;
}
inputModel.tap(MouseButtons.left); inputModel.tap(MouseButtons.left);
inputModel.tap(MouseButtons.left); inputModel.tap(MouseButtons.left);
} }
@@ -146,6 +163,7 @@ class _RawTouchGestureDetectorRegionState
if (handleTouch) { if (handleTouch) {
ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
_cacheLongPressPosition = d.localPosition; _cacheLongPressPosition = d.localPosition;
_cacheLongPressPositionTs = DateTime.now().millisecondsSinceEpoch;
} }
} }
@@ -222,9 +240,22 @@ class _RawTouchGestureDetectorRegionState
return; return;
} }
if (handleTouch) { if (handleTouch) {
if (isDesktop) { if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) {
return;
}
if (isDesktop || isWebDesktop) {
ffi.cursorModel.trySetRemoteWindowCoords(); ffi.cursorModel.trySetRemoteWindowCoords();
} }
// Workaround for the issue that the first pan event is sent a long time after the start event.
// If the time interval between the start event and the first pan event is less than 500ms,
// we consider to use the long press position as the start position.
//
// TODO: We should find a better way to send the first pan event as soon as possible.
if (DateTime.now().millisecondsSinceEpoch - _cacheLongPressPositionTs <
500) {
ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
}
inputModel.sendMouse('down', MouseButtons.left); inputModel.sendMouse('down', MouseButtons.left);
ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
} else { } else {
@@ -244,6 +275,9 @@ class _RawTouchGestureDetectorRegionState
if (lastDeviceKind != PointerDeviceKind.touch) { if (lastDeviceKind != PointerDeviceKind.touch) {
return; return;
} }
if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) {
return;
}
ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
} }
@@ -251,7 +285,7 @@ class _RawTouchGestureDetectorRegionState
if (lastDeviceKind != PointerDeviceKind.touch) { if (lastDeviceKind != PointerDeviceKind.touch) {
return; return;
} }
if (isDesktop) { if (isDesktop || isWebDesktop) {
ffi.cursorModel.clearRemoteWindowCoords(); ffi.cursorModel.clearRemoteWindowCoords();
} }
inputModel.sendMouse('up', MouseButtons.left); inputModel.sendMouse('up', MouseButtons.left);
@@ -281,7 +315,7 @@ class _RawTouchGestureDetectorRegionState
} }
} else { } else {
// mobile // mobile
ffi.canvasModel.updateScale(d.scale / _scale); ffi.canvasModel.updateScale(d.scale / _scale, d.focalPoint);
_scale = d.scale; _scale = d.scale;
ffi.canvasModel.panX(d.focalPointDelta.dx); ffi.canvasModel.panX(d.focalPointDelta.dx);
ffi.canvasModel.panY(d.focalPointDelta.dy); ffi.canvasModel.panY(d.focalPointDelta.dy);

View File

@@ -234,12 +234,12 @@ List<(String, String)> otherDefaultSettings() {
('True color (4:4:4)', kOptionI444), ('True color (4:4:4)', kOptionI444),
('Reverse mouse wheel', kKeyReverseMouseWheel), ('Reverse mouse wheel', kKeyReverseMouseWheel),
('swap-left-right-mouse', kOptionSwapLeftRightMouse), ('swap-left-right-mouse', kOptionSwapLeftRightMouse),
if (isDesktop && bind.mainGetUseTextureRender()) if (isDesktop)
( (
'Show displays as individual windows', 'Show displays as individual windows',
kKeyShowDisplaysAsIndividualWindows kKeyShowDisplaysAsIndividualWindows
), ),
if (isDesktop && bind.mainGetUseTextureRender()) if (isDesktop)
( (
'Use all my displays for the remote session', 'Use all my displays for the remote session',
kKeyUseAllMyDisplaysForTheRemoteSession kKeyUseAllMyDisplaysForTheRemoteSession

View File

@@ -6,6 +6,7 @@ import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -22,6 +23,20 @@ class TTextMenu {
required this.onPressed, required this.onPressed,
this.trailingIcon, this.trailingIcon,
this.divider = false}); this.divider = false});
Widget getChild() {
if (trailingIcon != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
child,
trailingIcon!,
],
);
} else {
return child;
}
}
} }
class TRadioMenu<T> { class TRadioMenu<T> {
@@ -115,12 +130,9 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
); );
} }
// paste // paste
if (isMobile && if (pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false) {
pi.platform != kPeerPlatformAndroid &&
perms['keyboard'] != false &&
perms['clipboard'] != false) {
v.add(TTextMenu( v.add(TTextMenu(
child: Text(translate('Paste')), child: Text(translate('Send clipboard keystrokes')),
onPressed: () async { onPressed: () async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) { if (data != null && data.text != null) {
@@ -583,8 +595,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
child: Text(translate('Lock after session end')))); child: Text(translate('Lock after session end'))));
} }
if (bind.mainGetUseTextureRender() && if (pi.isSupportMultiDisplay &&
pi.isSupportMultiDisplay &&
PrivacyModeState.find(id).isEmpty && PrivacyModeState.find(id).isEmpty &&
pi.displaysCount.value > 1 && pi.displaysCount.value > 1 &&
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') { bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
@@ -596,15 +607,13 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
bind.sessionSetDisplaysAsIndividualWindows( bind.sessionSetDisplaysAsIndividualWindows(
sessionId: sessionId, value: value ? 'Y' : ''); sessionId: sessionId, value: value ? 'Y' : 'N');
}, },
child: Text(translate('Show displays as individual windows')))); child: Text(translate('Show displays as individual windows'))));
} }
final isMultiScreens = !isWeb && (await getScreenRectList()).length > 1; final isMultiScreens = !isWeb && (await getScreenRectList()).length > 1;
if (bind.mainGetUseTextureRender() && if (pi.isSupportMultiDisplay && isMultiScreens) {
pi.isSupportMultiDisplay &&
isMultiScreens) {
final value = bind.sessionGetUseAllMyDisplaysForTheRemoteSession( final value = bind.sessionGetUseAllMyDisplaysForTheRemoteSession(
sessionId: ffi.sessionId) == sessionId: ffi.sessionId) ==
'Y'; 'Y';
@@ -613,7 +622,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
bind.sessionSetUseAllMyDisplaysForTheRemoteSession( bind.sessionSetUseAllMyDisplaysForTheRemoteSession(
sessionId: sessionId, value: value ? 'Y' : ''); sessionId: sessionId, value: value ? 'Y' : 'N');
}, },
child: Text(translate('Use all my displays for the remote session')))); child: Text(translate('Use all my displays for the remote session'))));
} }
@@ -639,6 +648,18 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
v.addAll(toolbarKeyboardToggles(ffi)); v.addAll(toolbarKeyboardToggles(ffi));
} }
// view mode (mobile only, desktop is in keyboard menu)
if (isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
v.add(TToggleMenu(
value: ffiModel.viewOnly,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(
sessionId: ffi.sessionId, value: kOptionToggleViewOnly);
ffiModel.setViewOnly(id, value);
},
child: Text(translate('View Mode'))));
}
return v; return v;
} }
@@ -779,3 +800,106 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
} }
return v; return v;
} }
bool showVirtualDisplayMenu(FFI ffi) {
if (ffi.ffiModel.pi.platform != kPeerPlatformWindows) {
return false;
}
if (!ffi.ffiModel.pi.isInstalled) {
return false;
}
if (ffi.ffiModel.pi.isRustDeskIdd || ffi.ffiModel.pi.isAmyuniIdd) {
return true;
}
return false;
}
List<Widget> getVirtualDisplayMenuChildren(
FFI ffi, String id, VoidCallback? clickCallBack) {
if (!showVirtualDisplayMenu(ffi)) {
return [];
}
final pi = ffi.ffiModel.pi;
final privacyModeState = PrivacyModeState.find(id);
if (pi.isRustDeskIdd) {
final virtualDisplays = ffi.ffiModel.pi.RustDeskVirtualDisplays;
final children = <Widget>[];
for (var i = 0; i < kMaxVirtualDisplayCount; i++) {
children.add(Obx(() => CkbMenuButton(
value: virtualDisplays.contains(i + 1),
onChanged: privacyModeState.isNotEmpty
? null
: (bool? value) async {
if (value != null) {
bind.sessionToggleVirtualDisplay(
sessionId: ffi.sessionId, index: i + 1, on: value);
clickCallBack?.call();
}
},
child: Text('${translate('Virtual display')} ${i + 1}'),
ffi: ffi,
)));
}
children.add(Divider());
children.add(Obx(() => MenuButton(
onPressed: privacyModeState.isNotEmpty
? null
: () {
bind.sessionToggleVirtualDisplay(
sessionId: ffi.sessionId,
index: kAllVirtualDisplay,
on: false);
clickCallBack?.call();
},
ffi: ffi,
child: Text(translate('Plug out all')),
)));
return children;
}
if (pi.isAmyuniIdd) {
final count = ffi.ffiModel.pi.amyuniVirtualDisplayCount;
final children = <Widget>[
Obx(() => Row(
children: [
TextButton(
onPressed: privacyModeState.isNotEmpty || count == 0
? null
: () {
bind.sessionToggleVirtualDisplay(
sessionId: ffi.sessionId, index: 0, on: false);
clickCallBack?.call();
},
child: Icon(Icons.remove),
),
Text(count.toString()),
TextButton(
onPressed: privacyModeState.isNotEmpty || count == 4
? null
: () {
bind.sessionToggleVirtualDisplay(
sessionId: ffi.sessionId, index: 0, on: true);
clickCallBack?.call();
},
child: Icon(Icons.add),
),
],
)),
Divider(),
Obx(() => MenuButton(
onPressed: privacyModeState.isNotEmpty || count == 0
? null
: () {
bind.sessionToggleVirtualDisplay(
sessionId: ffi.sessionId,
index: kAllVirtualDisplay,
on: false);
clickCallBack?.call();
},
ffi: ffi,
child: Text(translate('Plug out all')),
)),
];
return children;
}
return [];
}

View File

@@ -32,6 +32,7 @@ const String kPeerPlatformWindows = "Windows";
const String kPeerPlatformLinux = "Linux"; const String kPeerPlatformLinux = "Linux";
const String kPeerPlatformMacOS = "Mac OS"; const String kPeerPlatformMacOS = "Mac OS";
const String kPeerPlatformAndroid = "Android"; const String kPeerPlatformAndroid = "Android";
const String kPeerPlatformWebDesktop = "WebDesktop";
const double kScrollbarThickness = 12.0; const double kScrollbarThickness = 12.0;
@@ -133,9 +134,29 @@ const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
const String kOptionEnableCheckUpdate = "enable-check-update"; const String kOptionEnableCheckUpdate = "enable-check-update";
const String kOptionAllowLinuxHeadless = "allow-linux-headless"; const String kOptionAllowLinuxHeadless = "allow-linux-headless";
const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper"; const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
const String kOptionStopService = "stop-service";
const String kOptionDirectxCapture = "enable-directx-capture";
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
// buildin opitons
const String kOptionHideServerSetting = "hide-server-settings";
const String kOptionHideProxySetting = "hide-proxy-settings";
const String kOptionHideSecuritySetting = "hide-security-settings";
const String kOptionHideNetworkSetting = "hide-network-settings";
const String kOptionRemovePresetPasswordWarning =
"remove-preset-password-warning";
const kHideUsernameOnCard = "hide-username-on-card";
const String kOptionHideHelpCards = "hide-help-cards";
const String kOptionToggleViewOnly = "view-only"; const String kOptionToggleViewOnly = "view-only";
const String kOptionDisableFloatingWindow = "disable-floating-window";
const String kOptionKeepScreenOn = "keep-screen-on";
const String kOptionShowMobileAction = "showMobileActions";
const String kUrlActionClose = "close"; const String kUrlActionClose = "close";
const String kTabLabelHomePage = "Home"; const String kTabLabelHomePage = "Home";
@@ -147,6 +168,8 @@ const int kWindowMainId = 0;
const String kPointerEventKindTouch = "touch"; const String kPointerEventKindTouch = "touch";
const String kPointerEventKindMouse = "mouse"; const String kPointerEventKindMouse = "mouse";
const String kKeyFlutterKey = "flutter_key";
const String kKeyShowDisplaysAsIndividualWindows = const String kKeyShowDisplaysAsIndividualWindows =
'displays_as_individual_windows'; 'displays_as_individual_windows';
const String kKeyUseAllMyDisplaysForTheRemoteSession = const String kKeyUseAllMyDisplaysForTheRemoteSession =
@@ -178,7 +201,7 @@ const double kMinFps = 5;
const double kDefaultFps = 30; const double kDefaultFps = 30;
const double kMaxFps = 120; const double kMaxFps = 120;
const double kMinQuality = 10; const double kMinQuality = 5;
const double kDefaultQuality = 50; const double kDefaultQuality = 50;
const double kMaxQuality = 100; const double kMaxQuality = 100;
const double kMaxMoreQuality = 2000; const double kMaxMoreQuality = 2000;
@@ -219,9 +242,9 @@ const kDefaultScrollDuration = Duration(milliseconds: 50);
const kDefaultMouseWheelThrottleDuration = Duration(milliseconds: 50); const kDefaultMouseWheelThrottleDuration = Duration(milliseconds: 50);
const kFullScreenEdgeSize = 0.0; const kFullScreenEdgeSize = 0.0;
const kMaximizeEdgeSize = 0.0; const kMaximizeEdgeSize = 0.0;
// Do not use kWindowEdgeSize directly. Use `windowEdgeSize` in `common.dart` instead. // Do not use kWindowResizeEdgeSize directly. Use `windowResizeEdgeSize` in `common.dart` instead.
final kWindowEdgeSize = isWindows ? 1.0 : 5.0; const kWindowResizeEdgeSize = 5.0;
final kWindowBorderWidth = 1.0; const kWindowBorderWidth = 1.0;
const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0); const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0);
const kFrameBorderRadius = 12.0; const kFrameBorderRadius = 12.0;
const kFrameClipRRectBorderRadius = 12.0; const kFrameClipRRectBorderRadius = 12.0;
@@ -547,3 +570,5 @@ enum WindowsTarget {
extension WindowsTargetExt on int { extension WindowsTargetExt on int {
WindowsTarget get windowsVersion => getWindowsTarget(this); WindowsTarget get windowsVersion => getWindowsTarget(this);
} }
const kCheckSoftwareUpdateFinish = 'check_software_update_finish';

View File

@@ -3,8 +3,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -169,16 +169,12 @@ class _OnlineStatusWidgetState extends State<OnlineStatusWidget> {
final status = final status =
jsonDecode(await bind.mainGetConnectStatus()) as Map<String, dynamic>; jsonDecode(await bind.mainGetConnectStatus()) as Map<String, dynamic>;
final statusNum = status['status_num'] as int; final statusNum = status['status_num'] as int;
final preStatus = stateGlobal.svcStatus.value;
if (statusNum == 0) { if (statusNum == 0) {
stateGlobal.svcStatus.value = SvcStatus.connecting; stateGlobal.svcStatus.value = SvcStatus.connecting;
} else if (statusNum == -1) { } else if (statusNum == -1) {
stateGlobal.svcStatus.value = SvcStatus.notReady; stateGlobal.svcStatus.value = SvcStatus.notReady;
} else if (statusNum == 1) { } else if (statusNum == 1) {
stateGlobal.svcStatus.value = SvcStatus.ready; stateGlobal.svcStatus.value = SvcStatus.ready;
if (preStatus != SvcStatus.ready) {
gFFI.userModel.refreshCurrentUser();
}
} else { } else {
stateGlobal.svcStatus.value = SvcStatus.notReady; stateGlobal.svcStatus.value = SvcStatus.notReady;
} }
@@ -212,14 +208,14 @@ class _ConnectionPageState extends State<ConnectionPage>
void initState() { void initState() {
super.initState(); super.initState();
if (_idController.text.isEmpty) { if (_idController.text.isEmpty) {
() async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId(); final lastRemoteId = await bind.mainGetLastRemoteId();
if (lastRemoteId != _idController.id) { if (lastRemoteId != _idController.id) {
setState(() { setState(() {
_idController.id = lastRemoteId; _idController.id = lastRemoteId;
}); });
} }
}(); });
} }
Get.put<IDTextEditingController>(_idController); Get.put<IDTextEditingController>(_idController);
windowManager.addListener(this); windowManager.addListener(this);
@@ -261,8 +257,9 @@ class _ConnectionPageState extends State<ConnectionPage>
@override @override
void onWindowLeaveFullScreen() { void onWindowLeaveFullScreen() {
// Restore edge border to default edge size. // Restore edge border to default edge size.
stateGlobal.resizeEdgeSize.value = stateGlobal.resizeEdgeSize.value = stateGlobal.isMaximized.isTrue
stateGlobal.isMaximized.isTrue ? kMaximizeEdgeSize : windowEdgeSize; ? kMaximizeEdgeSize
: windowResizeEdgeSize;
} }
@override @override
@@ -326,36 +323,7 @@ class _ConnectionPageState extends State<ConnectionPage>
child: Ink( child: Ink(
child: Column( child: Column(
children: [ children: [
Row( getConnectionPageTitle(context, false).marginOnly(bottom: 15),
children: [
Expanded(
child: Row(
children: [
AutoSizeText(
translate('Control Remote Desktop'),
maxLines: 1,
style: Theme.of(context)
.textTheme
.titleLarge
?.merge(TextStyle(height: 1)),
).marginOnly(right: 4),
Tooltip(
waitDuration: Duration(milliseconds: 0),
message: translate("id_input_tip"),
child: Icon(
Icons.help_outline_outlined,
size: 16,
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.5),
),
),
],
)),
],
).marginOnly(bottom: 15),
Row( Row(
children: [ children: [
Expanded( Expanded(

View File

@@ -443,14 +443,14 @@ class _DesktopHomePageState extends State<DesktopHomePage>
}); });
} }
} else if (isMacOS) { } else if (isMacOS) {
if (!(bind.isOutgoingOnly() || final isOutgoingOnly = bind.isOutgoingOnly();
bind.mainIsCanScreenRecording(prompt: false))) { if (!(isOutgoingOnly || bind.mainIsCanScreenRecording(prompt: false))) {
return buildInstallCard("Permissions", "config_screen", "Configure", return buildInstallCard("Permissions", "config_screen", "Configure",
() async { () async {
bind.mainIsCanScreenRecording(prompt: true); bind.mainIsCanScreenRecording(prompt: true);
watchIsCanScreenRecording = true; watchIsCanScreenRecording = true;
}, help: 'Help', link: translate("doc_mac_permission")); }, help: 'Help', link: translate("doc_mac_permission"));
} else if (!bind.mainIsProcessTrusted(prompt: false)) { } else if (!isOutgoingOnly && !bind.mainIsProcessTrusted(prompt: false)) {
return buildInstallCard("Permissions", "config_acc", "Configure", return buildInstallCard("Permissions", "config_acc", "Configure",
() async { () async {
bind.mainIsProcessTrusted(prompt: true); bind.mainIsProcessTrusted(prompt: true);
@@ -462,7 +462,8 @@ class _DesktopHomePageState extends State<DesktopHomePage>
bind.mainIsCanInputMonitoring(prompt: true); bind.mainIsCanInputMonitoring(prompt: true);
watchIsInputMonitoring = true; watchIsInputMonitoring = true;
}, help: 'Help', link: translate("doc_mac_permission")); }, help: 'Help', link: translate("doc_mac_permission"));
} else if (!svcStopped.value && } else if (!isOutgoingOnly &&
!svcStopped.value &&
bind.mainIsInstalled() && bind.mainIsInstalled() &&
!bind.mainIsInstalledDaemon(prompt: false)) { !bind.mainIsInstalledDaemon(prompt: false)) {
return buildInstallCard("", "install_daemon_tip", "Install", () async { return buildInstallCard("", "install_daemon_tip", "Install", () async {
@@ -545,6 +546,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
String? link, String? link,
bool? closeButton, bool? closeButton,
String? closeOption}) { String? closeOption}) {
if (bind.mainGetBuildinOption(key: kOptionHideHelpCards) == 'Y' &&
content != 'install_daemon_tip') {
return const SizedBox();
}
void closeCard() async { void closeCard() async {
if (closeOption != null) { if (closeOption != null) {
await bind.mainSetLocalOption(key: closeOption, value: 'N'); await bind.mainSetLocalOption(key: closeOption, value: 'N');
@@ -659,9 +664,17 @@ class _DesktopHomePageState extends State<DesktopHomePage>
void initState() { void initState() {
super.initState(); super.initState();
if (!bind.isCustomClient()) { if (!bind.isCustomClient()) {
platformFFI.registerEventHandler(
kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish,
(Map<String, dynamic> evt) async {
if (evt['url'] is String) {
setState(() {
updateUrl = evt['url'];
});
}
});
Timer(const Duration(seconds: 1), () async { Timer(const Duration(seconds: 1), () async {
updateUrl = await bind.mainGetSoftwareUpdateUrl(); bind.mainGetSoftwareUpdateUrl();
if (updateUrl.isNotEmpty) setState(() {});
}); });
} }
_updateTimer = periodic_immediate(const Duration(seconds: 1), () async { _updateTimer = periodic_immediate(const Duration(seconds: 1), () async {
@@ -671,7 +684,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
systemError = error; systemError = error;
setState(() {}); setState(() {});
} }
final v = await bind.mainGetOption(key: "stop-service") == "Y"; final v = await mainGetBoolOption(kOptionStopService);
if (v != svcStopped.value) { if (v != svcStopped.value) {
svcStopped.value = v; svcStopped.value = v;
setState(() {}); setState(() {});
@@ -819,6 +832,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
_uniLinksSubscription?.cancel(); _uniLinksSubscription?.cancel();
Get.delete<RxBool>(tag: 'stop-service'); Get.delete<RxBool>(tag: 'stop-service');
_updateTimer?.cancel(); _updateTimer?.cancel();
if (!bind.isCustomClient()) {
platformFFI.unregisterEventHandler(
kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish);
}
super.dispose(); super.dispose();
} }
@@ -838,7 +855,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
} }
} }
void setPasswordDialog() async { void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
final pw = await bind.mainGetPermanentPassword(); final pw = await bind.mainGetPermanentPassword();
final p0 = TextEditingController(text: pw); final p0 = TextEditingController(text: pw);
final p1 = TextEditingController(text: pw); final p1 = TextEditingController(text: pw);
@@ -852,6 +869,7 @@ void setPasswordDialog() async {
// SpecialCharacterValidationRule(), // SpecialCharacterValidationRule(),
MinCharactersValidationRule(8), MinCharactersValidationRule(8),
]; ];
final maxLength = bind.mainMaxEncryptLen();
gFFI.dialogManager.show((setState, close, context) { gFFI.dialogManager.show((setState, close, context) {
submit() { submit() {
@@ -878,6 +896,9 @@ void setPasswordDialog() async {
return; return;
} }
bind.mainSetPermanentPassword(password: pass); bind.mainSetPermanentPassword(password: pass);
if (pass.isNotEmpty) {
notEmptyCallback?.call();
}
close(); close();
} }
@@ -907,6 +928,7 @@ void setPasswordDialog() async {
errMsg0 = ''; errMsg0 = '';
}); });
}, },
maxLength: maxLength,
), ),
), ),
], ],
@@ -933,6 +955,7 @@ void setPasswordDialog() async {
errMsg1 = ''; errMsg1 = '';
}); });
}, },
maxLength: maxLength,
), ),
), ),
], ],

View File

@@ -61,9 +61,14 @@ class DesktopSettingPage extends StatefulWidget {
final SettingsTabKey initialTabkey; final SettingsTabKey initialTabkey;
static final List<SettingsTabKey> tabKeys = [ static final List<SettingsTabKey> tabKeys = [
SettingsTabKey.general, SettingsTabKey.general,
if (!bind.isOutgoingOnly() && !bind.isDisableSettings()) if (!isWeb &&
!bind.isOutgoingOnly() &&
!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y')
SettingsTabKey.safety, SettingsTabKey.safety,
if (!bind.isDisableSettings()) SettingsTabKey.network, if (!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y')
SettingsTabKey.network,
if (!bind.isIncomingOnly()) SettingsTabKey.display, if (!bind.isIncomingOnly()) SettingsTabKey.display,
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled()) if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
SettingsTabKey.plugin, SettingsTabKey.plugin,
@@ -74,7 +79,8 @@ class DesktopSettingPage extends StatefulWidget {
DesktopSettingPage({Key? key, required this.initialTabkey}) : super(key: key); DesktopSettingPage({Key? key, required this.initialTabkey}) : super(key: key);
@override @override
State<DesktopSettingPage> createState() => _DesktopSettingPageState(); State<DesktopSettingPage> createState() =>
_DesktopSettingPageState(initialTabkey);
static void switch2page(SettingsTabKey page) { static void switch2page(SettingsTabKey page) {
try { try {
@@ -84,8 +90,10 @@ class DesktopSettingPage extends StatefulWidget {
} }
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) { if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) {
DesktopTabPage.onAddSetting(initialPage: page); DesktopTabPage.onAddSetting(initialPage: page);
PageController controller = Get.find(tag: _kSettingPageControllerTag); PageController controller =
Rx<SettingsTabKey> selected = Get.find(tag: _kSettingPageTabKeyTag); Get.find<PageController>(tag: _kSettingPageControllerTag);
Rx<SettingsTabKey> selected =
Get.find<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
selected.value = page; selected.value = page;
controller.jumpToPage(index); controller.jumpToPage(index);
} else { } else {
@@ -105,10 +113,8 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@override _DesktopSettingPageState(SettingsTabKey initialTabkey) {
void initState() { var initialIndex = DesktopSettingPage.tabKeys.indexOf(initialTabkey);
super.initState();
var initialIndex = DesktopSettingPage.tabKeys.indexOf(widget.initialTabkey);
if (initialIndex == -1) { if (initialIndex == -1) {
initialIndex = 0; initialIndex = 0;
} }
@@ -171,16 +177,32 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
} }
List<Widget> _children() { List<Widget> _children() {
final children = [ final children = List<Widget>.empty(growable: true);
_General(), for (final tab in DesktopSettingPage.tabKeys) {
if (!bind.isOutgoingOnly() && !bind.isDisableSettings()) _Safety(), switch (tab) {
if (!bind.isDisableSettings()) _Network(), case SettingsTabKey.general:
if (!bind.isIncomingOnly()) _Display(), children.add(const _General());
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled()) break;
_Plugin(), case SettingsTabKey.safety:
if (!bind.isDisableAccount()) _Account(), children.add(const _Safety());
_About(), break;
]; case SettingsTabKey.network:
children.add(const _Network());
break;
case SettingsTabKey.display:
children.add(const _Display());
break;
case SettingsTabKey.plugin:
children.add(const _Plugin());
break;
case SettingsTabKey.account:
children.add(const _Account());
break;
case SettingsTabKey.about:
children.add(const _About());
break;
}
}
return children; return children;
} }
@@ -195,7 +217,7 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
width: _kTabWidth, width: _kTabWidth,
child: Column( child: Column(
children: [ children: [
_header(), _header(context),
Flexible(child: _listView(tabs: _settingTabs())), Flexible(child: _listView(tabs: _settingTabs())),
], ],
), ),
@@ -218,12 +240,8 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
); );
} }
Widget _header() { Widget _header(BuildContext context) {
return Row( final settingsText = Text(
children: [
SizedBox(
height: 62,
child: Text(
translate('Settings'), translate('Settings'),
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
@@ -231,7 +249,30 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
fontSize: _kTitleFontSize, fontSize: _kTitleFontSize,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
);
return Row(
children: [
if (isWeb)
IconButton(
onPressed: () {
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
icon: Icon(Icons.arrow_back),
).marginOnly(left: 5),
if (isWeb)
SizedBox(
height: 62,
child: Align(
alignment: Alignment.center,
child: settingsText,
), ),
).marginOnly(left: 20),
if (!isWeb)
SizedBox(
height: 62,
child: settingsText,
).marginOnly(left: 20, top: 10), ).marginOnly(left: 20, top: 10),
const Spacer(), const Spacer(),
], ],
@@ -301,7 +342,8 @@ class _General extends StatefulWidget {
} }
class _GeneralState extends State<_General> { class _GeneralState extends State<_General> {
final RxBool serviceStop = Get.find<RxBool>(tag: 'stop-service'); final RxBool serviceStop =
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
RxBool serviceBtnEnabled = true.obs; RxBool serviceBtnEnabled = true.obs;
@override @override
@@ -313,13 +355,13 @@ class _GeneralState extends State<_General> {
physics: DraggableNeverScrollableScrollPhysics(), physics: DraggableNeverScrollableScrollPhysics(),
controller: scrollController, controller: scrollController,
children: [ children: [
service(), if (!isWeb) service(),
theme(), theme(),
hwcodec(),
audio(context),
record(context),
WaylandCard(),
_Card(title: 'Language', children: [language()]), _Card(title: 'Language', children: [language()]),
if (!isWeb) hwcodec(),
if (!isWeb) audio(context),
if (!isWeb) record(context),
if (!isWeb) WaylandCard(),
other() other()
], ],
).marginOnly(bottom: _kListViewBottomMargin)); ).marginOnly(bottom: _kListViewBottomMargin));
@@ -327,8 +369,8 @@ class _GeneralState extends State<_General> {
Widget theme() { Widget theme() {
final current = MyTheme.getThemeModePreference().toShortString(); final current = MyTheme.getThemeModePreference().toShortString();
onChanged(String value) { onChanged(String value) async {
MyTheme.changeDarkMode(MyTheme.themeModeFromString(value)); await MyTheme.changeDarkMode(MyTheme.themeModeFromString(value));
setState(() {}); setState(() {});
} }
@@ -373,13 +415,13 @@ class _GeneralState extends State<_General> {
Widget other() { Widget other() {
final children = <Widget>[ final children = <Widget>[
if (!bind.isIncomingOnly()) if (!isWeb && !bind.isIncomingOnly())
_OptionCheckBox(context, 'Confirm before closing multiple tabs', _OptionCheckBox(context, 'Confirm before closing multiple tabs',
kOptionEnableConfirmClosingTabs, kOptionEnableConfirmClosingTabs,
isServer: false), isServer: false),
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr), _OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
wallpaper(), if (!isWeb) wallpaper(),
if (!bind.isIncomingOnly()) ...[ if (!isWeb && !bind.isIncomingOnly()) ...[
_OptionCheckBox( _OptionCheckBox(
context, context,
'Open connection in new tab', 'Open connection in new tab',
@@ -396,6 +438,7 @@ class _GeneralState extends State<_General> {
kOptionAllowAlwaysSoftwareRender, kOptionAllowAlwaysSoftwareRender,
), ),
), ),
if (!isWeb)
Tooltip( Tooltip(
message: translate('texture_render_tip'), message: translate('texture_render_tip'),
child: _OptionCheckBox( child: _OptionCheckBox(
@@ -407,16 +450,22 @@ class _GeneralState extends State<_General> {
await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'), await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'),
), ),
), ),
if (!bind.isCustomClient()) if (!isWeb && !bind.isCustomClient())
_OptionCheckBox( _OptionCheckBox(
context, context,
'Check for software update on startup', 'Check for software update on startup',
kOptionEnableCheckUpdate, kOptionEnableCheckUpdate,
isServer: false, isServer: false,
),
if (isWindows && !bind.isOutgoingOnly())
_OptionCheckBox(
context,
'Capture screen using DirectX',
kOptionDirectxCapture,
) )
], ],
]; ];
if (bind.mainShowOption(key: kOptionAllowLinuxHeadless)) { if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
children.add(_OptionCheckBox( children.add(_OptionCheckBox(
context, 'Allow linux headless', kOptionAllowLinuxHeadless)); context, 'Allow linux headless', kOptionAllowLinuxHeadless));
} }
@@ -487,26 +536,28 @@ class _GeneralState extends State<_General> {
return const Offstage(); return const Offstage();
} }
return AudioInput(builder: (devices, currentDevice, setDevice) { builder(devices, currentDevice, setDevice) {
return _Card(title: 'Audio Input Device', children: [ final child = ComboBox(
...devices.map((device) => _Radio<String>(context, keys: devices,
value: device, values: devices,
groupValue: currentDevice, initialKey: currentDevice,
autoNewLine: false, onChanged: (key) async {
label: device, onChanged: (value) { setDevice(key);
setDevice(value);
setState(() {}); setState(() {});
})) },
]); ).marginOnly(left: _kContentHMargin);
}); return _Card(title: 'Audio Input Device', children: [child]);
}
return AudioInput(builder: builder, isCm: false, isVoiceCall: false);
} }
Widget record(BuildContext context) { Widget record(BuildContext context) {
final showRootDir = isWindows && bind.mainIsInstalled(); final showRootDir = isWindows && bind.mainIsInstalled();
return futureBuilder(future: () async { return futureBuilder(future: () async {
String user_dir = await bind.mainVideoSaveDirectory(root: false); String user_dir = bind.mainVideoSaveDirectory(root: false);
String root_dir = String root_dir =
showRootDir ? await bind.mainVideoSaveDirectory(root: true) : ''; showRootDir ? bind.mainVideoSaveDirectory(root: true) : '';
bool user_dir_exists = await Directory(user_dir).exists(); bool user_dir_exists = await Directory(user_dir).exists();
bool root_dir_exists = bool root_dir_exists =
showRootDir ? await Directory(root_dir).exists() : false; showRootDir ? await Directory(root_dir).exists() : false;
@@ -612,8 +663,9 @@ class _GeneralState extends State<_General> {
initialKey: currentKey, initialKey: currentKey,
onChanged: (key) async { onChanged: (key) async {
await bind.mainSetLocalOption(key: kCommConfKeyLang, value: key); await bind.mainSetLocalOption(key: kCommConfKeyLang, value: key);
reloadAllWindows(); if (isWeb) reloadCurrentWindow();
bind.mainChangeLanguage(lang: key); if (!isWeb) reloadAllWindows();
if (!isWeb) bind.mainChangeLanguage(lang: key);
}, },
enabled: !isOptFixed, enabled: !isOptFixed,
).marginOnly(left: _kContentHMargin); ).marginOnly(left: _kContentHMargin);
@@ -673,15 +725,24 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
// Simple temp wrapper for PR check // Simple temp wrapper for PR check
tmpWrapper() { tmpWrapper() {
RxBool has2fa = bind.mainHasValid2FaSync().obs; RxBool has2fa = bind.mainHasValid2FaSync().obs;
RxBool hasBot = bind.mainHasValidBotSync().obs;
update() async { update() async {
has2fa.value = bind.mainHasValid2FaSync(); has2fa.value = bind.mainHasValid2FaSync();
setState(() {});
} }
onChanged(bool? checked) async { onChanged(bool? checked) async {
if (checked == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-2fa-confirm-tip'), () {
change2fa(callback: update);
});
} else {
change2fa(callback: update); change2fa(callback: update);
} }
}
return GestureDetector( final tfa = GestureDetector(
child: InkWell( child: InkWell(
child: Obx(() => Row( child: Obx(() => Row(
children: [ children: [
@@ -702,6 +763,77 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
onChanged(!has2fa.value); onChanged(!has2fa.value);
}, },
).marginOnly(left: _kCheckBoxLeftMargin); ).marginOnly(left: _kCheckBoxLeftMargin);
if (!has2fa.value) {
return tfa;
}
updateBot() async {
hasBot.value = bind.mainHasValidBotSync();
setState(() {});
}
onChangedBot(bool? checked) async {
if (checked == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-bot-confirm-tip'), () {
changeBot(callback: updateBot);
});
} else {
changeBot(callback: updateBot);
}
}
final bot = GestureDetector(
child: Tooltip(
waitDuration: Duration(milliseconds: 300),
message: translate("enable-bot-tip"),
child: InkWell(
child: Obx(() => Row(
children: [
Checkbox(
value: hasBot.value,
onChanged: enabled ? onChangedBot : null)
.marginOnly(right: 5),
Expanded(
child: Text(
translate('Telegram bot'),
style: TextStyle(
color: disabledTextColor(context, enabled)),
))
],
))),
),
onTap: () {
onChangedBot(!hasBot.value);
},
).marginOnly(left: _kCheckBoxLeftMargin + 30);
final trust = Row(
children: [
Flexible(
child: Tooltip(
waitDuration: Duration(milliseconds: 300),
message: translate("enable-trusted-devices-tip"),
child: _OptionCheckBox(context, "Enable trusted devices",
kOptionEnableTrustedDevices,
enabled: !locked, update: (v) {
setState(() {});
}),
),
),
if (mainGetBoolOptionSync(kOptionEnableTrustedDevices))
ElevatedButton(
onPressed: locked
? null
: () {
manageTrustedDeviceDialog();
},
child: Text(translate('Manage trusted devices')))
],
).marginOnly(left: 30);
return Column(
children: [tfa, bot, trust],
);
} }
return tmpWrapper(); return tmpWrapper();
@@ -826,12 +958,22 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
label: value, label: value,
onChanged: locked onChanged: locked
? null ? null
: ((value) { : ((value) async {
() async { callback() async {
await model.setVerificationMethod( await model.setVerificationMethod(
passwordKeys[passwordValues.indexOf(value)]); passwordKeys[passwordValues.indexOf(value)]);
await model.updatePasswordModel(); await model.updatePasswordModel();
}(); }
if (value ==
passwordValues[passwordKeys
.indexOf(kUsePermanentPassword)] &&
(await bind.mainGetPermanentPassword())
.isEmpty) {
setPasswordDialog(notEmptyCallback: callback);
} else {
await callback();
}
}), }),
)) ))
.toList(); .toList();
@@ -924,6 +1066,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
_OptionCheckBox(context, 'allow-only-conn-window-open-tip', _OptionCheckBox(context, 'allow-only-conn-window-open-tip',
'allow-only-conn-window-open', 'allow-only-conn-window-open',
reverse: false, enabled: enabled), reverse: false, enabled: enabled),
if (bind.mainIsInstalled()) unlockPin()
]); ]);
} }
@@ -1024,12 +1167,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
bool enabled = !locked; bool enabled = !locked;
// Simple temp wrapper for PR check // Simple temp wrapper for PR check
tmpWrapper() { tmpWrapper() {
RxBool hasWhitelist = (bind.mainGetOptionSync(key: kOptionWhitelist) != RxBool hasWhitelist = whitelistNotEmpty().obs;
defaultOptionWhitelist)
.obs;
update() async { update() async {
hasWhitelist.value = bind.mainGetOptionSync(key: kOptionWhitelist) != hasWhitelist.value = whitelistNotEmpty();
defaultOptionWhitelist;
} }
onChanged(bool? checked) async { onChanged(bool? checked) async {
@@ -1140,7 +1280,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
width: 95, width: 95,
child: TextField( child: TextField(
controller: controller, controller: controller,
enabled: enabled && !locked && isOptFixed, enabled: enabled && !locked && !isOptFixed,
onChanged: (_) => applyEnabled.value = true, onChanged: (_) => applyEnabled.value = true,
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow(RegExp( FilteringTextInputFormatter.allow(RegExp(
@@ -1174,6 +1314,40 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
}(), }(),
]; ];
} }
Widget unlockPin() {
bool enabled = !locked;
RxString unlockPin = bind.mainGetUnlockPin().obs;
update() async {
unlockPin.value = bind.mainGetUnlockPin();
}
onChanged(bool? checked) async {
changeUnlockPinDialog(unlockPin.value, update);
}
final isOptFixed = isOptionFixed(kOptionWhitelist);
return GestureDetector(
child: Obx(() => Row(
children: [
Checkbox(
value: unlockPin.isNotEmpty,
onChanged: enabled && !isOptFixed ? onChanged : null)
.marginOnly(right: 5),
Expanded(
child: Text(
translate('Unlock with PIN'),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
],
)),
onTap: enabled
? () {
onChanged(!unlockPin.isNotEmpty);
}
: null,
).marginOnly(left: _kCheckBoxLeftMargin);
}
} }
class _Network extends StatefulWidget { class _Network extends StatefulWidget {
@@ -1186,13 +1360,18 @@ class _Network extends StatefulWidget {
class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin { class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
bool locked = bind.mainIsInstalled(); bool locked = !isWeb && bind.mainIsInstalled();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
bool enabled = !locked; bool enabled = !locked;
final scrollController = ScrollController(); final scrollController = ScrollController();
final hideServer =
bind.mainGetBuildinOption(key: kOptionHideServerSetting) == 'Y';
// TODO: support web proxy
final hideProxy =
isWeb || bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y';
return DesktopScrollWrapper( return DesktopScrollWrapper(
scrollController: scrollController, scrollController: scrollController,
child: ListView( child: ListView(
@@ -1206,7 +1385,8 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
AbsorbPointer( AbsorbPointer(
absorbing: locked, absorbing: locked,
child: Column(children: [ child: Column(children: [
server(enabled), if (!hideServer) server(enabled),
if (!hideProxy)
_Card(title: 'Proxy', children: [ _Card(title: 'Proxy', children: [
_Button('Socks5/Http(s) Proxy', changeSocks5Proxy, _Button('Socks5/Http(s) Proxy', changeSocks5Proxy,
enabled: enabled), enabled: enabled),
@@ -1271,6 +1451,7 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
children: [ children: [
Obx(() => _LabeledTextField(context, 'ID Server', idController, Obx(() => _LabeledTextField(context, 'ID Server', idController,
idErrMsg.value, enabled, secure)), idErrMsg.value, enabled, secure)),
if (!isWeb)
Obx(() => _LabeledTextField(context, 'Relay Server', Obx(() => _LabeledTextField(context, 'Relay Server',
relayController, relayErrMsg.value, enabled, secure)), relayController, relayErrMsg.value, enabled, secure)),
Obx(() => _LabeledTextField(context, 'API Server', Obx(() => _LabeledTextField(context, 'API Server',
@@ -1311,7 +1492,7 @@ class _DisplayState extends State<_Display> {
scrollStyle(context), scrollStyle(context),
imageQuality(context), imageQuality(context),
codec(context), codec(context),
privacyModeImpl(context), if (!isWeb) privacyModeImpl(context),
other(context), other(context),
]).marginOnly(bottom: _kListViewBottomMargin)); ]).marginOnly(bottom: _kListViewBottomMargin));
} }
@@ -1709,7 +1890,7 @@ class _AboutState extends State<_About> {
child: SingleChildScrollView( child: SingleChildScrollView(
controller: scrollController, controller: scrollController,
physics: DraggableNeverScrollableScrollPhysics(), physics: DraggableNeverScrollableScrollPhysics(),
child: _Card(title: '${translate('About')} RustDesk', children: [ child: _Card(title: translate('About RustDesk'), children: [
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1722,6 +1903,7 @@ class _AboutState extends State<_About> {
SelectionArea( SelectionArea(
child: Text('${translate('Build Date')}: $buildDate') child: Text('${translate('Build Date')}: $buildDate')
.marginSymmetric(vertical: 4.0)), .marginSymmetric(vertical: 4.0)),
if (!isWeb)
SelectionArea( SelectionArea(
child: Text('${translate('Fingerprint')}: $fingerprint') child: Text('${translate('Fingerprint')}: $fingerprint')
.marginSymmetric(vertical: 4.0)), .marginSymmetric(vertical: 4.0)),
@@ -2064,10 +2246,15 @@ Widget _lock(
Text(translate(label)).marginOnly(left: 5), Text(translate(label)).marginOnly(left: 5),
]).marginSymmetric(vertical: 2)), ]).marginSymmetric(vertical: 2)),
onPressed: () async { onPressed: () async {
final unlockPin = bind.mainGetUnlockPin();
if (unlockPin.isEmpty) {
bool checked = await callMainCheckSuperUserPermission(); bool checked = await callMainCheckSuperUserPermission();
if (checked) { if (checked) {
onUnlock(); onUnlock();
} }
} else {
checkUnlockPinDialog(unlockPin, onUnlock);
}
}, },
).marginSymmetric(horizontal: 2, vertical: 4), ).marginSymmetric(horizontal: 2, vertical: 4),
).marginOnly(left: _kCardLeftMargin), ).marginOnly(left: _kCardLeftMargin),
@@ -2243,6 +2430,7 @@ void changeSocks5Proxy() async {
children: [ children: [
Row( Row(
children: [ children: [
if (!isMobile)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140), constraints: const BoxConstraints(minWidth: 140),
child: Align( child: Align(
@@ -2272,6 +2460,10 @@ void changeSocks5Proxy() async {
child: TextField( child: TextField(
decoration: InputDecoration( decoration: InputDecoration(
errorText: proxyMsg.isNotEmpty ? proxyMsg : null, errorText: proxyMsg.isNotEmpty ? proxyMsg : null,
labelText: isMobile ? translate('Server') : null,
helperText:
isMobile ? translate("default_proxy_tip") : null,
helperMaxLines: isMobile ? 3 : null,
), ),
controller: proxyController, controller: proxyController,
autofocus: true, autofocus: true,
@@ -2282,6 +2474,7 @@ void changeSocks5Proxy() async {
).marginOnly(bottom: 8), ).marginOnly(bottom: 8),
Row( Row(
children: [ children: [
if (!isMobile)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140), constraints: const BoxConstraints(minWidth: 140),
child: Text( child: Text(
@@ -2291,6 +2484,9 @@ void changeSocks5Proxy() async {
Expanded( Expanded(
child: TextField( child: TextField(
controller: userController, controller: userController,
decoration: InputDecoration(
labelText: isMobile ? translate('Username') : null,
),
enabled: !isOptFixed, enabled: !isOptFixed,
), ),
), ),
@@ -2298,6 +2494,7 @@ void changeSocks5Proxy() async {
).marginOnly(bottom: 8), ).marginOnly(bottom: 8),
Row( Row(
children: [ children: [
if (!isMobile)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140), constraints: const BoxConstraints(minWidth: 140),
child: Text( child: Text(
@@ -2308,6 +2505,7 @@ void changeSocks5Proxy() async {
child: Obx(() => TextField( child: Obx(() => TextField(
obscureText: obscure.value, obscureText: obscure.value,
decoration: InputDecoration( decoration: InputDecoration(
labelText: isMobile ? translate('Password') : null,
suffixIcon: IconButton( suffixIcon: IconButton(
onPressed: () => obscure.value = !obscure.value, onPressed: () => obscure.value = !obscure.value,
icon: Icon(obscure.value icon: Icon(obscure.value
@@ -2315,6 +2513,7 @@ void changeSocks5Proxy() async {
: Icons.visibility))), : Icons.visibility))),
controller: pwdController, controller: pwdController,
enabled: !isOptFixed, enabled: !isOptFixed,
maxLength: bind.mainMaxEncryptLen(),
)), )),
), ),
], ],

View File

@@ -8,6 +8,7 @@ import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
// import 'package:flutter/services.dart';
import '../../common/shared_state.dart'; import '../../common/shared_state.dart';
@@ -20,7 +21,7 @@ class DesktopTabPage extends StatefulWidget {
static void onAddSetting( static void onAddSetting(
{SettingsTabKey initialPage = SettingsTabKey.general}) { {SettingsTabKey initialPage = SettingsTabKey.general}) {
try { try {
DesktopTabController tabController = Get.find(); DesktopTabController tabController = Get.find<DesktopTabController>();
tabController.add(TabInfo( tabController.add(TabInfo(
key: kTabLabelSettingPage, key: kTabLabelSettingPage,
label: kTabLabelSettingPage, label: kTabLabelSettingPage,
@@ -36,14 +37,16 @@ class DesktopTabPage extends StatefulWidget {
} }
} }
class _DesktopTabPageState extends State<DesktopTabPage> { class _DesktopTabPageState extends State<DesktopTabPage>
with WidgetsBindingObserver {
final tabController = DesktopTabController(tabType: DesktopTabType.main); final tabController = DesktopTabController(tabType: DesktopTabType.main);
@override final RxBool _block = false.obs;
void initState() { // bool mouseIn = false;
super.initState();
Get.put<DesktopTabController>(tabController); _DesktopTabPageState() {
RemoteCountState.init(); RemoteCountState.init();
Get.put<DesktopTabController>(tabController);
tabController.add(TabInfo( tabController.add(TabInfo(
key: kTabLabelHomePage, key: kTabLabelHomePage,
label: kTabLabelHomePage, label: kTabLabelHomePage,
@@ -66,10 +69,38 @@ class _DesktopTabPageState extends State<DesktopTabPage> {
} }
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
shouldBeBlocked(_block, canBeBlocked);
} else if (state == AppLifecycleState.inactive) {}
}
@override
void initState() {
super.initState();
// HardwareKeyboard.instance.addHandler(_handleKeyEvent);
WidgetsBinding.instance.addObserver(this);
}
/*
bool _handleKeyEvent(KeyEvent event) {
if (!mouseIn && event is KeyDownEvent) {
print('key down: ${event.logicalKey}');
shouldBeBlocked(_block, canBeBlocked);
}
return false; // allow it to propagate
}
*/
@override @override
void dispose() { void dispose() {
super.dispose(); // HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
WidgetsBinding.instance.removeObserver(this);
Get.delete<DesktopTabController>(); Get.delete<DesktopTabController>();
super.dispose();
} }
@override @override
@@ -88,12 +119,14 @@ class _DesktopTabPageState extends State<DesktopTabPage> {
isClose: false, isClose: false,
), ),
), ),
blockTab: _block,
))); )));
return isMacOS || kUseCompatibleUiMode return isMacOS || kUseCompatibleUiMode
? tabWidget ? tabWidget
: Obx( : Obx(
() => DragToResizeArea( () => DragToResizeArea(
resizeEdgeSize: stateGlobal.resizeEdgeSize.value, resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: windowManagerEnableResizeEdges,
child: tabWidget, child: tabWidget,
), ),
); );

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:extended_text/extended_text.dart';
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart'; import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
import 'package:percent_indicator/percent_indicator.dart'; import 'package:percent_indicator/percent_indicator.dart';
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
@@ -16,6 +17,8 @@ import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:flutter_hbb/web/dummy.dart'
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
import '../../consts.dart'; import '../../consts.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
@@ -54,21 +57,21 @@ class FileManagerPage extends StatefulWidget {
required this.id, required this.id,
required this.password, required this.password,
required this.isSharedPassword, required this.isSharedPassword,
required this.tabController, this.tabController,
this.forceRelay}) this.forceRelay})
: super(key: key); : super(key: key);
final String id; final String id;
final String? password; final String? password;
final bool? isSharedPassword; final bool? isSharedPassword;
final bool? forceRelay; final bool? forceRelay;
final DesktopTabController tabController; final DesktopTabController? tabController;
@override @override
State<StatefulWidget> createState() => _FileManagerPageState(); State<StatefulWidget> createState() => _FileManagerPageState();
} }
class _FileManagerPageState extends State<FileManagerPage> class _FileManagerPageState extends State<FileManagerPage>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
final _mouseFocusScope = Rx<MouseFocusScope>(MouseFocusScope.none); final _mouseFocusScope = Rx<MouseFocusScope>(MouseFocusScope.none);
final _dropMaskVisible = false.obs; // TODO impl drop mask final _dropMaskVisible = false.obs; // TODO impl drop mask
@@ -92,13 +95,20 @@ class _FileManagerPageState extends State<FileManagerPage>
_ffi.dialogManager _ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection); .showLoading(translate('Connecting...'), onCancel: closeConnection);
}); });
Get.put(_ffi, tag: 'ft_${widget.id}'); Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
if (!isLinux) { if (!isLinux) {
WakelockPlus.enable(); WakelockPlus.enable();
} }
if (isWeb) {
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
debugPrint("File manager page init success with id ${widget.id}"); debugPrint("File manager page init success with id ${widget.id}");
_ffi.dialogManager.setOverlayState(_overlayKeyState); _ffi.dialogManager.setOverlayState(_overlayKeyState);
widget.tabController.onSelected?.call(widget.id); // Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController?.onSelected?.call(widget.id);
});
WidgetsBinding.instance.addObserver(this);
} }
@override @override
@@ -111,12 +121,21 @@ class _FileManagerPageState extends State<FileManagerPage>
} }
Get.delete<FFI>(tag: 'ft_${widget.id}'); Get.delete<FFI>(tag: 'ft_${widget.id}');
}); });
WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
jobController.jobTable.refresh();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@@ -126,6 +145,7 @@ class _FileManagerPageState extends State<FileManagerPage>
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Row( body: Row(
children: [ children: [
if (!isWeb)
Flexible( Flexible(
flex: 3, flex: 3,
child: dropArea(FileManagerView( child: dropArea(FileManagerView(
@@ -170,10 +190,31 @@ class _FileManagerPageState extends State<FileManagerPage>
/// transfer status list /// transfer status list
/// watch transfer status /// watch transfer status
Widget statusList() { Widget statusList() {
Widget getIcon(JobProgress job) {
final color = Theme.of(context).tabBarTheme.labelColor;
switch (job.type) {
case JobType.deleteDir:
case JobType.deleteFile:
return Icon(Icons.delete_outline, color: color);
default:
return Transform.rotate(
angle: isWeb
? job.isRemoteToLocal
? pi / 2
: pi / 2 * 3
: job.isRemoteToLocal
? pi
: 0,
child: Icon(Icons.arrow_forward_ios, color: color),
);
}
}
statusListView(List<JobProgress> jobs) => ListView.builder( statusListView(List<JobProgress> jobs) => ListView.builder(
controller: ScrollController(), controller: ScrollController(),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final item = jobs[index]; final item = jobs[index];
final status = item.getStatus();
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 5), padding: const EdgeInsets.only(bottom: 5),
child: generateCard( child: generateCard(
@@ -183,15 +224,8 @@ class _FileManagerPageState extends State<FileManagerPage>
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Transform.rotate( getIcon(item)
angle: item.isRemoteToLocal ? pi : 0, .marginSymmetric(horizontal: 10, vertical: 12),
child: SvgPicture.asset("assets/arrow.svg",
colorFilter: svgColor(
Theme.of(context).tabBarTheme.labelColor)),
).paddingOnly(left: 15),
const SizedBox(
width: 16.0,
),
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -200,45 +234,28 @@ class _FileManagerPageState extends State<FileManagerPage>
Tooltip( Tooltip(
waitDuration: Duration(milliseconds: 500), waitDuration: Duration(milliseconds: 500),
message: item.jobName, message: item.jobName,
child: Text( child: ExtendedText(
item.fileName, item.jobName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).paddingSymmetric(vertical: 10), overflowWidget: TextOverflowWidget(
child: Text("..."),
position: TextOverflowPosition.start),
), ),
Text( ),
'${translate("Total")} ${readableFileSize(item.totalSize.toDouble())}', Tooltip(
waitDuration: Duration(milliseconds: 500),
message: status,
child: Text(status,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: MyTheme.darkGray, color: MyTheme.darkGray,
), )).marginOnly(top: 6),
), ),
Offstage( Offstage(
offstage: item.state != JobState.inProgress, offstage: item.type != JobType.transfer ||
child: Text( item.state != JobState.inProgress,
'${translate("Speed")} ${readableFileSize(item.speed)}/s',
style: TextStyle(
fontSize: 12,
color: MyTheme.darkGray,
),
),
),
Offstage(
offstage: item.state == JobState.inProgress,
child: Text(
translate(
item.display(),
),
style: TextStyle(
fontSize: 12,
color: MyTheme.darkGray,
),
),
),
Offstage(
offstage: item.state != JobState.inProgress,
child: LinearPercentIndicator( child: LinearPercentIndicator(
padding: EdgeInsets.only(right: 15),
animateFromLastPercent: true, animateFromLastPercent: true,
center: Text( center: Text(
'${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%', '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
@@ -248,7 +265,7 @@ class _FileManagerPageState extends State<FileManagerPage>
progressColor: MyTheme.accent, progressColor: MyTheme.accent,
backgroundColor: Theme.of(context).hoverColor, backgroundColor: Theme.of(context).hoverColor,
lineHeight: kDesktopFileTransferRowHeight, lineHeight: kDesktopFileTransferRowHeight,
).paddingSymmetric(vertical: 15), ).paddingSymmetric(vertical: 8),
), ),
], ],
), ),
@@ -259,6 +276,7 @@ class _FileManagerPageState extends State<FileManagerPage>
Offstage( Offstage(
offstage: item.state != JobState.paused, offstage: item.state != JobState.paused,
child: MenuButton( child: MenuButton(
tooltip: translate("Resume"),
onPressed: () { onPressed: () {
jobController.resumeJob(item.id); jobController.resumeJob(item.id);
}, },
@@ -271,7 +289,7 @@ class _FileManagerPageState extends State<FileManagerPage>
), ),
), ),
MenuButton( MenuButton(
padding: EdgeInsets.only(right: 15), tooltip: translate("Delete"),
child: SvgPicture.asset( child: SvgPicture.asset(
"assets/close.svg", "assets/close.svg",
colorFilter: svgColor(Colors.white), colorFilter: svgColor(Colors.white),
@@ -284,11 +302,11 @@ class _FileManagerPageState extends State<FileManagerPage>
hoverColor: MyTheme.accent80, hoverColor: MyTheme.accent80,
), ),
], ],
), ).marginAll(12),
], ],
), ),
], ],
).paddingSymmetric(vertical: 10), ),
), ),
); );
}, },
@@ -472,6 +490,9 @@ class _FileManagerViewState extends State<FileManagerView> {
} }
Widget headTools() { Widget headTools() {
var uploadButtonTapPosition = RelativeRect.fill;
RxBool isUploadFolder =
(bind.mainGetLocalOption(key: 'upload-folder-button') == 'Y').obs;
return Container( return Container(
child: Column( child: Column(
children: [ children: [
@@ -518,6 +539,7 @@ class _FileManagerViewState extends State<FileManagerView> {
Row( Row(
children: [ children: [
MenuButton( MenuButton(
tooltip: translate('Back'),
padding: EdgeInsets.only( padding: EdgeInsets.only(
right: 3, right: 3,
), ),
@@ -537,6 +559,7 @@ class _FileManagerViewState extends State<FileManagerView> {
}, },
), ),
MenuButton( MenuButton(
tooltip: translate('Parent directory'),
child: RotatedBox( child: RotatedBox(
quarterTurns: 3, quarterTurns: 3,
child: SvgPicture.asset( child: SvgPicture.asset(
@@ -601,6 +624,7 @@ class _FileManagerViewState extends State<FileManagerView> {
switch (_locationStatus.value) { switch (_locationStatus.value) {
case LocationStatus.bread: case LocationStatus.bread:
return MenuButton( return MenuButton(
tooltip: translate('Search'),
onPressed: () { onPressed: () {
_locationStatus.value = LocationStatus.fileSearchBar; _locationStatus.value = LocationStatus.fileSearchBar;
Future.delayed( Future.delayed(
@@ -627,6 +651,7 @@ class _FileManagerViewState extends State<FileManagerView> {
); );
case LocationStatus.fileSearchBar: case LocationStatus.fileSearchBar:
return MenuButton( return MenuButton(
tooltip: translate('Clear'),
onPressed: () { onPressed: () {
onSearchText("", isLocal); onSearchText("", isLocal);
_locationStatus.value = LocationStatus.bread; _locationStatus.value = LocationStatus.bread;
@@ -642,6 +667,7 @@ class _FileManagerViewState extends State<FileManagerView> {
} }
}), }),
MenuButton( MenuButton(
tooltip: translate('Refresh File'),
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 3, left: 3,
), ),
@@ -667,6 +693,7 @@ class _FileManagerViewState extends State<FileManagerView> {
isLocal ? MainAxisAlignment.start : MainAxisAlignment.end, isLocal ? MainAxisAlignment.start : MainAxisAlignment.end,
children: [ children: [
MenuButton( MenuButton(
tooltip: translate('Home'),
padding: EdgeInsets.only( padding: EdgeInsets.only(
right: 3, right: 3,
), ),
@@ -682,11 +709,27 @@ class _FileManagerViewState extends State<FileManagerView> {
hoverColor: Theme.of(context).hoverColor, hoverColor: Theme.of(context).hoverColor,
), ),
MenuButton( MenuButton(
tooltip: translate('Create Folder'),
onPressed: () { onPressed: () {
final name = TextEditingController(); final name = TextEditingController();
String? errorText;
_ffi.dialogManager.show((setState, close, context) { _ffi.dialogManager.show((setState, close, context) {
name.addListener(() {
if (errorText != null) {
setState(() {
errorText = null;
});
}
});
submit() { submit() {
if (name.value.text.isNotEmpty) { if (name.value.text.isNotEmpty) {
if (!PathUtil.validName(name.value.text,
controller.options.value.isWindows)) {
setState(() {
errorText = translate("Invalid folder name");
});
return;
}
controller.createDir(PathUtil.join( controller.createDir(PathUtil.join(
controller.directory.value.path, controller.directory.value.path,
name.value.text, name.value.text,
@@ -718,6 +761,7 @@ class _FileManagerViewState extends State<FileManagerView> {
labelText: translate( labelText: translate(
"Please enter the folder name", "Please enter the folder name",
), ),
errorText: errorText,
), ),
controller: name, controller: name,
autofocus: true, autofocus: true,
@@ -751,6 +795,7 @@ class _FileManagerViewState extends State<FileManagerView> {
hoverColor: Theme.of(context).hoverColor, hoverColor: Theme.of(context).hoverColor,
), ),
Obx(() => MenuButton( Obx(() => MenuButton(
tooltip: translate('Delete'),
onPressed: SelectedItems.valid(selectedItems.items) onPressed: SelectedItems.valid(selectedItems.items)
? () async { ? () async {
await (controller await (controller
@@ -770,6 +815,66 @@ class _FileManagerViewState extends State<FileManagerView> {
], ],
), ),
), ),
if (isWeb)
Obx(() => ElevatedButton.icon(
style: ButtonStyle(
padding: MaterialStateProperty.all<EdgeInsetsGeometry>(
isLocal
? EdgeInsets.only(left: 10)
: EdgeInsets.only(right: 10)),
backgroundColor: MaterialStateProperty.all(
selectedItems.items.isEmpty
? MyTheme.accent80
: MyTheme.accent,
),
),
onPressed: () =>
{webselectFiles(is_folder: isUploadFolder.value)},
label: InkWell(
hoverColor: Colors.transparent,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
focusColor: Colors.transparent,
onTapDown: (e) {
final x = e.globalPosition.dx;
final y = e.globalPosition.dy;
uploadButtonTapPosition =
RelativeRect.fromLTRB(x, y, x, y);
},
onTap: () async {
final value = await showMenu<bool>(
context: context,
position: uploadButtonTapPosition,
items: [
PopupMenuItem<bool>(
value: false,
child: Text(translate('Upload files')),
),
PopupMenuItem<bool>(
value: true,
child: Text(translate('Upload folder')),
),
]);
if (value != null) {
isUploadFolder.value = value;
bind.mainSetLocalOption(
key: 'upload-folder-button',
value: value ? 'Y' : '');
webselectFiles(is_folder: value);
}
},
child: Icon(Icons.arrow_drop_down),
),
icon: Text(
translate(isUploadFolder.isTrue
? 'Upload folder'
: 'Upload files'),
textAlign: TextAlign.right,
style: TextStyle(
color: Colors.white,
),
).marginOnly(left: 8),
)).marginOnly(left: 16),
Obx(() => ElevatedButton.icon( Obx(() => ElevatedButton.icon(
style: ButtonStyle( style: ButtonStyle(
padding: MaterialStateProperty.all<EdgeInsetsGeometry>( padding: MaterialStateProperty.all<EdgeInsetsGeometry>(
@@ -803,11 +908,14 @@ class _FileManagerViewState extends State<FileManagerView> {
: Colors.white, : Colors.white,
), ),
) )
: isWeb
? Offstage()
: RotatedBox( : RotatedBox(
quarterTurns: 2, quarterTurns: 2,
child: SvgPicture.asset( child: SvgPicture.asset(
"assets/arrow.svg", "assets/arrow.svg",
colorFilter: svgColor(selectedItems.items.isEmpty colorFilter: svgColor(
selectedItems.items.isEmpty
? Theme.of(context).brightness == ? Theme.of(context).brightness ==
Brightness.light Brightness.light
? MyTheme.grayBg ? MyTheme.grayBg
@@ -827,7 +935,7 @@ class _FileManagerViewState extends State<FileManagerView> {
: Colors.white), : Colors.white),
) )
: Text( : Text(
translate('Receive'), translate(isWeb ? 'Download' : 'Receive'),
style: TextStyle( style: TextStyle(
color: selectedItems.items.isEmpty color: selectedItems.items.isEmpty
? Theme.of(context).brightness == ? Theme.of(context).brightness ==
@@ -882,6 +990,7 @@ class _FileManagerViewState extends State<FileManagerView> {
menuPos = RelativeRect.fromLTRB(x, y, x, y); menuPos = RelativeRect.fromLTRB(x, y, x, y);
}, },
child: MenuButton( child: MenuButton(
tooltip: translate('More'),
onPressed: () => mod_menu.showMenu( onPressed: () => mod_menu.showMenu(
context: context, context: context,
position: menuPos, position: menuPos,
@@ -913,6 +1022,7 @@ class _FileManagerViewState extends State<FileManagerView> {
BuildContext context, ScrollController scrollController) { BuildContext context, ScrollController scrollController) {
final fd = controller.directory.value; final fd = controller.directory.value;
final entries = fd.entries; final entries = fd.entries;
Rx<Entry?> rightClickEntry = Rx(null);
return ListSearchActionListener( return ListSearchActionListener(
node: _keyboardNode, node: _keyboardNode,
@@ -971,16 +1081,70 @@ class _FileManagerViewState extends State<FileManagerView> {
final lastModifiedStr = entry.isDrive final lastModifiedStr = entry.isDrive
? " " ? " "
: "${entry.lastModified().toString().replaceAll(".000", "")} "; : "${entry.lastModified().toString().replaceAll(".000", "")} ";
var secondaryPosition = RelativeRect.fromLTRB(0, 0, 0, 0);
onTap() {
final items = selectedItems;
// handle double click
if (_checkDoubleClick(entry)) {
controller.openDirectory(entry.path);
items.clear();
return;
}
_onSelectedChanged(items, filteredEntries, entry, isLocal);
}
onSecondaryTap() {
final items = [
if (!entry.isDrive &&
versionCmp(_ffi.ffiModel.pi.version, "1.3.0") >= 0)
mod_menu.PopupMenuItem(
child: Text(translate("Rename")),
height: CustomPopupMenuTheme.height,
onTap: () {
controller.renameAction(entry, isLocal);
},
)
];
if (items.isNotEmpty) {
rightClickEntry.value = entry;
final future = mod_menu.showMenu(
context: context,
position: secondaryPosition,
items: items,
);
future.then((value) {
rightClickEntry.value = null;
});
future.onError((error, stackTrace) {
rightClickEntry.value = null;
});
}
}
onSecondaryTapDown(details) {
secondaryPosition = RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy);
}
return Padding( return Padding(
padding: EdgeInsets.symmetric(vertical: 1), padding: EdgeInsets.symmetric(vertical: 1),
child: Obx(() => Container( child: Obx(() => Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: selectedItems.items.contains(entry) color: selectedItems.items.contains(entry)
? Theme.of(context).hoverColor ? MyTheme.button
: Theme.of(context).cardColor, : Theme.of(context).cardColor,
borderRadius: BorderRadius.all( borderRadius: BorderRadius.all(
Radius.circular(5.0), Radius.circular(5.0),
), ),
border: rightClickEntry.value == entry
? Border.all(
color: MyTheme.button,
width: 1.0,
)
: null,
), ),
key: ValueKey(entry.name), key: ValueKey(entry.name),
height: kDesktopFileTransferRowHeight, height: kDesktopFileTransferRowHeight,
@@ -1019,22 +1183,19 @@ class _FileManagerViewState extends State<FileManagerView> {
), ),
Expanded( Expanded(
child: Text(entry.name.nonBreaking, child: Text(entry.name.nonBreaking,
style: TextStyle(
color: selectedItems.items
.contains(entry)
? Colors.white
: null),
overflow: overflow:
TextOverflow.ellipsis)) TextOverflow.ellipsis))
]), ]),
)), )),
), ),
onTap: () { onTap: onTap,
final items = selectedItems; onSecondaryTap: onSecondaryTap,
// handle double click onSecondaryTapDown: onSecondaryTapDown,
if (_checkDoubleClick(entry)) {
controller.openDirectory(entry.path);
items.clear();
return;
}
_onSelectedChanged(
items, filteredEntries, entry, isLocal);
},
), ),
SizedBox( SizedBox(
width: 2.0, width: 2.0,
@@ -1051,11 +1212,17 @@ class _FileManagerViewState extends State<FileManagerView> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: MyTheme.darkGray, color: selectedItems.items
.contains(entry)
? Colors.white70
: MyTheme.darkGray,
), ),
)), )),
), ),
), ),
onTap: onTap,
onSecondaryTap: onSecondaryTap,
onSecondaryTapDown: onSecondaryTapDown,
), ),
// Divider from header. // Divider from header.
SizedBox( SizedBox(
@@ -1071,9 +1238,16 @@ class _FileManagerViewState extends State<FileManagerView> {
sizeStr, sizeStr,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 10, color: MyTheme.darkGray), fontSize: 10,
color:
selectedItems.items.contains(entry)
? Colors.white70
: MyTheme.darkGray),
), ),
), ),
onTap: onTap,
onSecondaryTap: onSecondaryTap,
onSecondaryTapDown: onSecondaryTapDown,
), ),
), ),
], ],

View File

@@ -34,6 +34,7 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
WindowController.fromWindowId(windowId()) WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id)); .setTitle(getWindowNameWithId(id));
}; };
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.add(TabInfo( tabController.add(TabInfo(
key: params['id'], key: params['id'],
label: params['id'], label: params['id'],
@@ -54,8 +55,6 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
void initState() { void initState() {
super.initState(); super.initState();
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler((call, fromWindowId) async { rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
print( print(
"[FileTransfer] call ${call.method} with args ${call.arguments} from window $fromWindowId to ${windowId()}"); "[FileTransfer] call ${call.method} with args ${call.arguments} from window $fromWindowId to ${windowId()}");
@@ -97,6 +96,7 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
controller: tabController, controller: tabController,
onWindowCloseButton: handleWindowCloseButton, onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(), tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter, labelGetter: DesktopTab.tablabelGetter,
)); ));
final tabWidget = isLinux final tabWidget = isLinux
@@ -111,6 +111,7 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
: SubWindowDragToResizeArea( : SubWindowDragToResizeArea(
child: tabWidget, child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value, resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId, windowId: stateGlobal.windowId,
); );
} }

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
@@ -19,9 +21,7 @@ class InstallPage extends StatefulWidget {
class _InstallPageState extends State<InstallPage> { class _InstallPageState extends State<InstallPage> {
final tabController = DesktopTabController(tabType: DesktopTabType.main); final tabController = DesktopTabController(tabType: DesktopTabType.main);
@override _InstallPageState() {
void initState() {
super.initState();
Get.put<DesktopTabController>(tabController); Get.put<DesktopTabController>(tabController);
const label = "install"; const label = "install";
tabController.add(TabInfo( tabController.add(TabInfo(
@@ -43,6 +43,7 @@ class _InstallPageState extends State<InstallPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DragToResizeArea( return DragToResizeArea(
resizeEdgeSize: stateGlobal.resizeEdgeSize.value, resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: windowManagerEnableResizeEdges,
child: Container( child: Container(
child: Scaffold( child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.background, backgroundColor: Theme.of(context).colorScheme.background,
@@ -73,10 +74,16 @@ class _InstallPageBodyState extends State<_InstallPageBody>
padding: EdgeInsets.symmetric(vertical: 15, horizontal: 12), padding: EdgeInsets.symmetric(vertical: 15, horizontal: 12),
); );
_InstallPageBodyState() {
controller = TextEditingController(text: bind.installInstallPath());
final installOptions = jsonDecode(bind.installInstallOptions());
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
}
@override @override
void initState() { void initState() {
windowManager.addListener(this); windowManager.addListener(this);
controller = TextEditingController(text: bind.installInstallPath());
super.initState(); super.initState();
} }
@@ -248,6 +255,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
if (desktopicon.value) args += ' desktopicon'; if (desktopicon.value) args += ' desktopicon';
bind.installInstallMe(options: args, path: controller.text); bind.installInstallMe(options: args, path: controller.text);
} }
do_install(); do_install();
} }

View File

@@ -63,9 +63,12 @@ class _PortForwardPageState extends State<PortForwardPage>
isSharedPassword: widget.isSharedPassword, isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay, forceRelay: widget.forceRelay,
isRdp: widget.isRDP); isRdp: widget.isRDP);
Get.put(_ffi, tag: 'pf_${widget.id}'); Get.put<FFI>(_ffi, tag: 'pf_${widget.id}');
debugPrint("Port forward page init success with id ${widget.id}"); debugPrint("Port forward page init success with id ${widget.id}");
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id); widget.tabController.onSelected?.call(widget.id);
});
} }
@override @override
@@ -141,8 +144,9 @@ class _PortForwardPageState extends State<PortForwardPage>
child: Text(translate(label)).marginOnly(left: _kTextLeftMargin)); child: Text(translate(label)).marginOnly(left: _kTextLeftMargin));
return Theme( return Theme(
data: Theme.of(context) data: Theme.of(context).copyWith(
.copyWith(backgroundColor: Theme.of(context).colorScheme.background), colorScheme: Theme.of(context).colorScheme,
),
child: Obx(() => ListView.builder( child: Obx(() => ListView.builder(
controller: ScrollController(), controller: ScrollController(),
itemCount: pfs.length + 2, itemCount: pfs.length + 2,
@@ -289,7 +293,7 @@ class _PortForwardPageState extends State<PortForwardPage>
).marginOnly(left: _kTextLeftMargin)); ).marginOnly(left: _kTextLeftMargin));
return Theme( return Theme(
data: Theme.of(context) data: Theme.of(context)
.copyWith(backgroundColor: Theme.of(context).colorScheme.background), .copyWith(colorScheme: Theme.of(context).colorScheme),
child: ListView.builder( child: ListView.builder(
controller: ScrollController(), controller: ScrollController(),
itemCount: 2, itemCount: 2,

View File

@@ -34,6 +34,7 @@ class _PortForwardTabPageState extends State<PortForwardTabPage> {
WindowController.fromWindowId(windowId()) WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id)); .setTitle(getWindowNameWithId(id));
}; };
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.add(TabInfo( tabController.add(TabInfo(
key: params['id'], key: params['id'],
label: params['id'], label: params['id'],
@@ -54,8 +55,6 @@ class _PortForwardTabPageState extends State<PortForwardTabPage> {
void initState() { void initState() {
super.initState(); super.initState();
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler((call, fromWindowId) async { rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
debugPrint( debugPrint(
"[Port Forward] call ${call.method} with args ${call.arguments} from window $fromWindowId"); "[Port Forward] call ${call.method} with args ${call.arguments} from window $fromWindowId");
@@ -106,6 +105,7 @@ class _PortForwardTabPageState extends State<PortForwardTabPage> {
return true; return true;
}, },
tail: AddButton(), tail: AddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter, labelGetter: DesktopTab.tablabelGetter,
), ),
); );
@@ -127,6 +127,7 @@ class _PortForwardTabPageState extends State<PortForwardTabPage> {
() => SubWindowDragToResizeArea( () => SubWindowDragToResizeArea(
child: tabWidget, child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value, resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId, windowId: stateGlobal.windowId,
), ),
); );

View File

@@ -45,7 +45,9 @@ class RemotePage extends StatefulWidget {
this.switchUuid, this.switchUuid,
this.forceRelay, this.forceRelay,
this.isSharedPassword, this.isSharedPassword,
}) : super(key: key); }) : super(key: key) {
initSharedStates(id);
}
final String id; final String id;
final SessionID? sessionId; final SessionID? sessionId;
@@ -64,7 +66,7 @@ class RemotePage extends StatefulWidget {
@override @override
State<RemotePage> createState() { State<RemotePage> createState() {
final state = _RemotePageState(); final state = _RemotePageState(id);
_lastState.value = state; _lastState.value = state;
return state; return state;
} }
@@ -85,14 +87,20 @@ class _RemotePageState extends State<RemotePage>
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode"); final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
// to identify the toolbar instance and its callback function.
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
Function(bool)? _onEnterOrLeaveImage4Toolbar; Function(bool)? _onEnterOrLeaveImage4Toolbar;
late FFI _ffi; late FFI _ffi;
SessionID get sessionId => _ffi.sessionId; SessionID get sessionId => _ffi.sessionId;
_RemotePageState(String id) {
_initStates(id);
}
void _initStates(String id) { void _initStates(String id) {
initSharedStates(id);
_zoomCursor = PeerBoolOption.find(id, kOptionZoomCursor); _zoomCursor = PeerBoolOption.find(id, kOptionZoomCursor);
_showRemoteCursor = ShowRemoteCursorState.find(id); _showRemoteCursor = ShowRemoteCursorState.find(id);
_keyboardEnabled = KeyboardEnabledState.find(id); _keyboardEnabled = KeyboardEnabledState.find(id);
@@ -102,9 +110,8 @@ class _RemotePageState extends State<RemotePage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initStates(widget.id);
_ffi = FFI(widget.sessionId); _ffi = FFI(widget.sessionId);
Get.put(_ffi, tag: widget.id); Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) { _ffi.imageModel.addCallbackOnFirstImage((String peerId) {
showKBLayoutTypeChooserIfNeeded( showKBLayoutTypeChooserIfNeeded(
_ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.ffiModel.pi.platform, _ffi.dialogManager);
@@ -131,11 +138,14 @@ class _RemotePageState extends State<RemotePage>
_ffi.ffiModel.updateEventListener(sessionId, widget.id); _ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote); if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
_ffi.dialogManager.loadMobileActionsOverlayVisible();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Session option should be set after models.dart/FFI.start // Session option should be set after models.dart/FFI.start
_showRemoteCursor.value = bind.sessionGetToggleOptionSync( _showRemoteCursor.value = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'show-remote-cursor'); sessionId: sessionId, arg: 'show-remote-cursor');
_zoomCursor.value = bind.sessionGetToggleOptionSync( _zoomCursor.value = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: kOptionZoomCursor); sessionId: sessionId, arg: kOptionZoomCursor);
});
DesktopMultiWindow.addListener(this); DesktopMultiWindow.addListener(this);
// if (!_isCustomCursorInited) { // if (!_isCustomCursorInited) {
// customCursorController.registerNeedUpdateCursorCallback( // customCursorController.registerNeedUpdateCursorCallback(
@@ -150,7 +160,10 @@ class _RemotePageState extends State<RemotePage>
// } // }
_blockableOverlayState.applyFfi(_ffi); _blockableOverlayState.applyFfi(_ffi);
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController?.onSelected?.call(widget.id); widget.tabController?.onSelected?.call(widget.id);
});
} }
@override @override
@@ -232,8 +245,10 @@ class _RemotePageState extends State<RemotePage>
super.dispose(); super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
_ffi.textureModel.onRemotePageDispose(closeSession); _ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) {
// ensure we leave this session, this is a double check // ensure we leave this session, this is a double check
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
}
DesktopMultiWindow.removeListener(this); DesktopMultiWindow.removeListener(this);
_ffi.dialogManager.hideMobileActionsOverlay(); _ffi.dialogManager.hideMobileActionsOverlay();
_ffi.imageModel.disposeImage(); _ffi.imageModel.disposeImage();
@@ -268,9 +283,18 @@ class _RemotePageState extends State<RemotePage>
id: widget.id, id: widget.id,
ffi: _ffi, ffi: _ffi,
state: widget.toolbarState, state: widget.toolbarState,
onEnterOrLeaveImageSetter: (func) => onEnterOrLeaveImageSetter: (id, func) {
_onEnterOrLeaveImage4Toolbar = func, _instanceIdOnEnterOrLeaveImage4Toolbar = id;
onEnterOrLeaveImageCleaner: () => _onEnterOrLeaveImage4Toolbar = null, _onEnterOrLeaveImage4Toolbar = func;
},
onEnterOrLeaveImageCleaner: (id) {
// If _instanceIdOnEnterOrLeaveImage4Toolbar != id
// it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar.
if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) {
_instanceIdOnEnterOrLeaveImage4Toolbar = null;
_onEnterOrLeaveImage4Toolbar = null;
}
},
setRemoteState: setState, setRemoteState: setState,
); );
@@ -310,22 +334,13 @@ class _RemotePageState extends State<RemotePage>
if (!_ffi.ffiModel.isPeerAndroid) { if (!_ffi.ffiModel.isPeerAndroid) {
return Offstage(); return Offstage();
} else { } else {
if (_ffi.connType == ConnType.defaultConn &&
_ffi.ffiModel.permissions['keyboard'] != false) {
Timer(
Duration(milliseconds: 10),
() => _ffi.dialogManager
.mobileActionsOverlayVisible.value = true);
}
return Obx(() => Offstage( return Obx(() => Offstage(
offstage: _ffi.dialogManager offstage: _ffi.dialogManager
.mobileActionsOverlayVisible.isFalse, .mobileActionsOverlayVisible.isFalse,
child: Overlay(initialEntries: [ child: Overlay(initialEntries: [
makeMobileActionsOverlayEntry( makeMobileActionsOverlayEntry(
() => _ffi () => _ffi.dialogManager
.dialogManager .setMobileActionsOverlayVisible(false),
.mobileActionsOverlayVisible
.value = false,
ffi: _ffi, ffi: _ffi,
) )
]), ]),
@@ -484,6 +499,7 @@ class _RemotePageState extends State<RemotePage>
() => _ffi.ffiModel.pi.isSet.isFalse () => _ffi.ffiModel.pi.isSet.isFalse
? Container(color: Colors.transparent) ? Container(color: Colors.transparent)
: Obx(() { : Obx(() {
widget.toolbarState.initShow(sessionId);
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value); _ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint( return ImagePaint(
id: widget.id, id: widget.id,
@@ -501,12 +517,13 @@ class _RemotePageState extends State<RemotePage>
]; ];
if (!_ffi.canvasModel.cursorEmbedded) { if (!_ffi.canvasModel.cursorEmbedded) {
paints.add(Obx(() => Offstage( paints
offstage: _showRemoteCursor.isFalse || _remoteCursorMoved.isFalse, .add(Obx(() => _showRemoteCursor.isFalse || _remoteCursorMoved.isFalse
child: CursorPaint( ? Offstage()
: CursorPaint(
id: widget.id, id: widget.id,
zoomCursor: _zoomCursor, zoomCursor: _zoomCursor,
)))); )));
} }
paints.add( paints.add(
Positioned( Positioned(
@@ -559,11 +576,6 @@ class _ImagePaintState extends State<ImagePaint> {
RxBool get remoteCursorMoved => widget.remoteCursorMoved; RxBool get remoteCursorMoved => widget.remoteCursorMoved;
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder; Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
@override
void initState() {
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context); final m = Provider.of<ImageModel>(context);
@@ -617,7 +629,8 @@ class _ImagePaintState extends State<ImagePaint> {
final paintWidth = c.getDisplayWidth() * s; final paintWidth = c.getDisplayWidth() * s;
final paintHeight = c.getDisplayHeight() * s; final paintHeight = c.getDisplayHeight() * s;
final paintSize = Size(paintWidth, paintHeight); final paintSize = Size(paintWidth, paintHeight);
final paintWidget = m.useTextureRender final paintWidget =
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
? _BuildPaintTextureRender( ? _BuildPaintTextureRender(
c, s, Offset.zero, paintSize, isViewOriginal()) c, s, Offset.zero, paintSize, isViewOriginal())
: _buildScrollbarNonTextureRender(m, paintSize, s); : _buildScrollbarNonTextureRender(m, paintSize, s);
@@ -638,7 +651,8 @@ class _ImagePaintState extends State<ImagePaint> {
)); ));
} else { } else {
if (c.size.width > 0 && c.size.height > 0) { if (c.size.width > 0 && c.size.height > 0) {
final paintWidget = m.useTextureRender final paintWidget =
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
? _BuildPaintTextureRender( ? _BuildPaintTextureRender(
c, c,
s, s,
@@ -648,7 +662,7 @@ class _ImagePaintState extends State<ImagePaint> {
), ),
c.size, c.size,
isViewOriginal()) isViewOriginal())
: _buildScrollAuthNonTextureRender(m, c, s); : _buildScrollAutoNonTextureRender(m, c, s);
return mouseRegion(child: _buildListener(paintWidget)); return mouseRegion(child: _buildListener(paintWidget));
} else { } else {
return Container(); return Container();
@@ -664,7 +678,7 @@ class _ImagePaintState extends State<ImagePaint> {
); );
} }
Widget _buildScrollAuthNonTextureRender( Widget _buildScrollAutoNonTextureRender(
ImageModel m, CanvasModel c, double s) { ImageModel m, CanvasModel c, double s) {
return CustomPaint( return CustomPaint(
size: Size(c.size.width, c.size.height), size: Size(c.size.width, c.size.height),

View File

@@ -46,7 +46,6 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
static const IconData selectedIcon = Icons.desktop_windows_sharp; static const IconData selectedIcon = Icons.desktop_windows_sharp;
static const IconData unselectedIcon = Icons.desktop_windows_outlined; static const IconData unselectedIcon = Icons.desktop_windows_outlined;
late ToolbarState _toolbarState;
String? peerId; String? peerId;
bool _isScreenRectSet = false; bool _isScreenRectSet = false;
int? _display; int? _display;
@@ -54,7 +53,6 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
var connectionMap = RxList<Widget>.empty(growable: true); var connectionMap = RxList<Widget>.empty(growable: true);
_ConnectionTabPageState(Map<String, dynamic> params) { _ConnectionTabPageState(Map<String, dynamic> params) {
_toolbarState = ToolbarState();
RemoteCountState.init(); RemoteCountState.init();
peerId = params['id']; peerId = params['id'];
final sessionId = params['session_id']; final sessionId = params['session_id'];
@@ -73,7 +71,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final ffi = remotePage.ffi; final ffi = remotePage.ffi;
bind.setCurSessionId(sessionId: ffi.sessionId); bind.setCurSessionId(sessionId: ffi.sessionId);
} }
WindowController.fromWindowId(windowId()) WindowController.fromWindowId(params['windowId'])
.setTitle(getWindowNameWithId(id)); .setTitle(getWindowNameWithId(id));
UnreadChatCountState.find(id).value = 0; UnreadChatCountState.find(id).value = 0;
}; };
@@ -91,7 +89,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
display: display, display: display,
displays: displays?.cast<int>(), displays: displays?.cast<int>(),
password: params['password'], password: params['password'],
toolbarState: _toolbarState, toolbarState: ToolbarState(),
tabController: tabController, tabController: tabController,
switchUuid: params['switch_uuid'], switchUuid: params['switch_uuid'],
forceRelay: params['forceRelay'], forceRelay: params['forceRelay'],
@@ -100,15 +98,14 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
)); ));
_update_remote_count(); _update_remote_count();
} }
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
if (!_isScreenRectSet) { if (!_isScreenRectSet) {
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
restoreWindowPosition( restoreWindowPosition(
@@ -123,12 +120,6 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
} }
} }
@override
void dispose() {
super.dispose();
_toolbarState.save();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final child = Scaffold( final child = Scaffold(
@@ -137,6 +128,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
controller: tabController, controller: tabController,
onWindowCloseButton: handleWindowCloseButton, onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(), tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView, pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter, labelGetter: DesktopTab.tablabelGetter,
tabBuilder: (key, icon, label, themeConf) => Obx(() { tabBuilder: (key, icon, label, themeConf) => Obx(() {
@@ -236,6 +228,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
// Specially configured for a better resize area and remote control. // Specially configured for a better resize area and remote control.
childPadding: kDragToResizeAreaPadding, childPadding: kDragToResizeAreaPadding,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value, resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId, windowId: stateGlobal.windowId,
)); ));
} }
@@ -251,15 +244,16 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final pi = ffi.ffiModel.pi; final pi = ffi.ffiModel.pi;
final perms = ffi.ffiModel.permissions; final perms = ffi.ffiModel.permissions;
final sessionId = ffi.sessionId; final sessionId = ffi.sessionId;
final toolbarState = remotePage.toolbarState;
menu.addAll([ menu.addAll([
MenuEntryButton<String>( MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Obx(() => Text( childBuilder: (TextStyle? style) => Obx(() => Text(
translate( translate(
_toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'), toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
style: style, style: style,
)), )),
proc: () { proc: () {
_toolbarState.switchShow(); toolbarState.switchShow(sessionId);
cancelFunc(); cancelFunc();
}, },
padding: padding, padding: padding,
@@ -415,19 +409,19 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final display = args['display']; final display = args['display'];
final displays = args['displays']; final displays = args['displays'];
final screenRect = parseParamScreenRect(args); final screenRect = parseParamScreenRect(args);
final prePeerCount = tabController.length;
Future.delayed(Duration.zero, () async { Future.delayed(Duration.zero, () async {
if (stateGlobal.fullscreen.isTrue) { if (stateGlobal.fullscreen.isTrue) {
await WindowController.fromWindowId(windowId()).setFullscreen(false); await WindowController.fromWindowId(windowId()).setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false); stateGlobal.setFullscreen(false, procWnd: false);
} }
await setNewConnectWindowFrame(windowId(), id!, screenRect); await setNewConnectWindowFrame(
windowId(), id!, prePeerCount, display, screenRect);
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async { Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
await windowOnTop(windowId()); await windowOnTop(windowId());
}); });
}); });
ConnectionTypeState.init(id); ConnectionTypeState.init(id);
_toolbarState.setShow(
bind.mainGetUserDefaultOption(key: kOptionCollapseToolbar) != 'Y');
tabController.add(TabInfo( tabController.add(TabInfo(
key: id, key: id,
label: id, label: id,
@@ -442,7 +436,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
display: display, display: display,
displays: displays?.cast<int>(), displays: displays?.cast<int>(),
password: args['password'], password: args['password'],
toolbarState: _toolbarState, toolbarState: ToolbarState(),
tabController: tabController, tabController: tabController,
switchUuid: switchUuid, switchUuid: switchUuid,
forceRelay: args['forceRelay'], forceRelay: args['forceRelay'],

View File

@@ -32,14 +32,18 @@ class DesktopServerPage extends StatefulWidget {
class _DesktopServerPageState extends State<DesktopServerPage> class _DesktopServerPageState extends State<DesktopServerPage>
with WindowListener, AutomaticKeepAliveClientMixin { with WindowListener, AutomaticKeepAliveClientMixin {
final tabController = gFFI.serverModel.tabController; final tabController = gFFI.serverModel.tabController;
@override
void initState() { _DesktopServerPageState() {
gFFI.ffiModel.updateEventListener(gFFI.sessionId, ""); gFFI.ffiModel.updateEventListener(gFFI.sessionId, "");
windowManager.addListener(this); Get.put<DesktopTabController>(tabController);
Get.put(tabController);
tabController.onRemoved = (_, id) { tabController.onRemoved = (_, id) {
onRemoveId(id); onRemoveId(id);
}; };
}
@override
void initState() {
windowManager.addListener(this);
super.initState(); super.initState();
} }
@@ -79,7 +83,7 @@ class _DesktopServerPageState extends State<DesktopServerPage>
child: Consumer<ServerModel>( child: Consumer<ServerModel>(
builder: (context, serverModel, child) { builder: (context, serverModel, child) {
final body = Scaffold( final body = Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).colorScheme.background,
body: ConnectionManager(), body: ConnectionManager(),
); );
return isLinux return isLinux
@@ -104,10 +108,11 @@ class ConnectionManager extends StatefulWidget {
State<StatefulWidget> createState() => ConnectionManagerState(); State<StatefulWidget> createState() => ConnectionManagerState();
} }
class ConnectionManagerState extends State<ConnectionManager> { class ConnectionManagerState extends State<ConnectionManager>
@override with WidgetsBindingObserver {
void initState() { final RxBool _block = false.obs;
gFFI.serverModel.updateClientState();
ConnectionManagerState() {
gFFI.serverModel.tabController.onSelected = (client_id_str) { gFFI.serverModel.tabController.onSelected = (client_id_str) {
final client_id = int.tryParse(client_id_str); final client_id = int.tryParse(client_id_str);
if (client_id != null) { if (client_id != null) {
@@ -116,7 +121,7 @@ class ConnectionManagerState extends State<ConnectionManager> {
if (client != null) { if (client != null) {
gFFI.chatModel.changeCurrentKey(MessageKey(client.peerId, client.id)); gFFI.chatModel.changeCurrentKey(MessageKey(client.peerId, client.id));
if (client.unreadChatMessageCount.value > 0) { if (client.unreadChatMessageCount.value > 0) {
Future.delayed(Duration.zero, () { WidgetsBinding.instance.addPostFrameCallback((_) {
client.unreadChatMessageCount.value = 0; client.unreadChatMessageCount.value = 0;
gFFI.chatModel.showChatPage(MessageKey(client.peerId, client.id)); gFFI.chatModel.showChatPage(MessageKey(client.peerId, client.id));
}); });
@@ -127,9 +132,31 @@ class ConnectionManagerState extends State<ConnectionManager> {
} }
}; };
gFFI.chatModel.isConnManager = true; gFFI.chatModel.isConnManager = true;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
if (!allowRemoteCMModification()) {
shouldBeBlocked(_block, null);
}
}
}
@override
void initState() {
gFFI.serverModel.updateClientState();
WidgetsBinding.instance.addObserver(this);
super.initState(); super.initState();
} }
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context); final serverModel = Provider.of<ServerModel>(context);
@@ -165,8 +192,7 @@ class ConnectionManagerState extends State<ConnectionManager> {
selectedBorderColor: MyTheme.accent, selectedBorderColor: MyTheme.accent,
maxLabelWidth: 100, maxLabelWidth: 100,
tail: null, //buildScrollJumper(), tail: null, //buildScrollJumper(),
selectedTabBackgroundColor: blockTab: allowRemoteCMModification() ? null : _block,
Theme.of(context).hintColor.withOpacity(0),
tabBuilder: (key, icon, label, themeConf) { tabBuilder: (key, icon, label, themeConf) {
final client = serverModel.clients final client = serverModel.clients
.firstWhereOrNull((client) => client.id.toString() == key); .firstWhereOrNull((client) => client.id.toString() == key);
@@ -201,27 +227,28 @@ class ConnectionManagerState extends State<ConnectionManager> {
borderWidth; borderWidth;
final realChatPageWidth = final realChatPageWidth =
constrains.maxWidth - realClosedWidth; constrains.maxWidth - realClosedWidth;
return Row(children: [ final row = Row(children: [
if (constrains.maxWidth > if (constrains.maxWidth >
kConnectionManagerWindowSizeClosedChat.width) kConnectionManagerWindowSizeClosedChat.width)
Consumer<ChatModel>( Consumer<ChatModel>(
builder: (_, model, child) => SizedBox( builder: (_, model, child) => SizedBox(
width: realChatPageWidth, width: realChatPageWidth,
child: buildRemoteBlock( child: allowRemoteCMModification()
child: Container( ? buildSidePage()
decoration: BoxDecoration( : buildRemoteBlock(
border: Border( child: buildSidePage(),
right: BorderSide( block: _block,
color: Theme.of(context) mask: true),
.dividerColor))),
child: buildSidePage()),
),
)), )),
SizedBox( SizedBox(
width: realClosedWidth, width: realClosedWidth,
child: child:
SizedBox(width: realClosedWidth, child: pageView)), SizedBox(width: realClosedWidth, child: pageView)),
]); ]);
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: row,
);
}, },
), ),
), ),
@@ -381,7 +408,10 @@ class _CmHeaderState extends State<_CmHeader>
_time.value = _time.value + 1; _time.value = _time.value + 1;
} }
}); });
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
WidgetsBinding.instance.addPostFrameCallback((_) {
gFFI.serverModel.tabController.onSelected?.call(client.id.toString()); gFFI.serverModel.tabController.onSelected?.call(client.id.toString());
});
} }
@override @override
@@ -714,7 +744,8 @@ class _CmControlPanel extends StatelessWidget {
child: buildButton(context, child: buildButton(context,
color: MyTheme.accent, color: MyTheme.accent,
onClick: null, onTapDown: (details) async { onClick: null, onTapDown: (details) async {
final devicesInfo = await AudioInput.getDevicesInfo(); final devicesInfo =
await AudioInput.getDevicesInfo(true, true);
List<String> devices = devicesInfo['devices'] as List<String>; List<String> devices = devicesInfo['devices'] as List<String>;
if (devices.isEmpty) { if (devices.isEmpty) {
msgBox( msgBox(
@@ -740,13 +771,14 @@ class _CmControlPanel extends StatelessWidget {
value: d, value: d,
height: 18, height: 18,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onTap: () => AudioInput.setDevice(d), onTap: () => AudioInput.setDevice(d, true, true),
child: IgnorePointer( child: IgnorePointer(
child: RadioMenuButton( child: RadioMenuButton(
value: d, value: d,
groupValue: currentDevice, groupValue: currentDevice,
onChanged: (v) { onChanged: (v) {
if (v != null) AudioInput.setDevice(v); if (v != null)
AudioInput.setDevice(v, true, true);
}, },
child: Container( child: Container(
child: Text( child: Text(
@@ -1043,6 +1075,10 @@ class _CmControlPanel extends StatelessWidget {
} }
void checkClickTime(int id, Function() callback) async { void checkClickTime(int id, Function() callback) async {
if (allowRemoteCMModification()) {
callback();
return;
}
var clickCallbackTime = DateTime.now().millisecondsSinceEpoch; var clickCallbackTime = DateTime.now().millisecondsSinceEpoch;
await bind.cmCheckClickTime(connId: id); await bind.cmCheckClickTime(connId: id);
Timer(const Duration(milliseconds: 120), () async { Timer(const Duration(milliseconds: 120), () async {
@@ -1051,6 +1087,11 @@ void checkClickTime(int id, Function() callback) async {
}); });
} }
bool allowRemoteCMModification() {
return option2bool(kOptionAllowRemoteCmModification,
bind.mainGetLocalOption(key: kOptionAllowRemoteCmModification));
}
class _FileTransferLogPage extends StatefulWidget { class _FileTransferLogPage extends StatefulWidget {
_FileTransferLogPage({Key? key}) : super(key: key); _FileTransferLogPage({Key? key}) : super(key: key);
@@ -1116,6 +1157,16 @@ class __FileTransferLogPageState extends State<_FileTransferLogPage> {
Text(translate('Create Folder')) Text(translate('Create Folder'))
], ],
); );
case CmFileAction.rename:
return Column(
children: [
Icon(
Icons.drive_file_move_outlined,
color: Theme.of(context).tabBarTheme.labelColor,
),
Text(translate('Rename'))
],
);
} }
} }

View File

@@ -178,8 +178,9 @@ String getLocalPlatformForKBLayoutType(String peerPlatform) {
localPlatform = kPeerPlatformWindows; localPlatform = kPeerPlatformWindows;
} else if (isLinux) { } else if (isLinux) {
localPlatform = kPeerPlatformLinux; localPlatform = kPeerPlatformLinux;
} else if (isWebOnWindows || isWebOnLinux) {
localPlatform = kPeerPlatformWebDesktop;
} }
// to-do: web desktop support ?
return localPlatform; return localPlatform;
} }

View File

@@ -271,7 +271,7 @@ class PopupMenuItem<T> extends PopupMenuEntry<T> {
/// The text style of the popup menu item. /// The text style of the popup menu item.
/// ///
/// If this property is null, then [PopupMenuThemeData.textStyle] is used. /// If this property is null, then [PopupMenuThemeData.textStyle] is used.
/// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.subtitle1] /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium]
/// of [ThemeData.textTheme] is used. /// of [ThemeData.textTheme] is used.
final TextStyle? textStyle; final TextStyle? textStyle;
@@ -352,7 +352,7 @@ class PopupMenuItemState<T, W extends PopupMenuItem<T>> extends State<W> {
final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
TextStyle style = widget.textStyle ?? TextStyle style = widget.textStyle ??
popupMenuTheme.textStyle ?? popupMenuTheme.textStyle ??
theme.textTheme.subtitle1!; theme.textTheme.titleMedium!;
if (!widget.enabled) style = style.copyWith(color: theme.disabledColor); if (!widget.enabled) style = style.copyWith(color: theme.disabledColor);

View File

@@ -34,6 +34,7 @@ class _MenuButtonState extends State<MenuButton> {
return Padding( return Padding(
padding: widget.padding, padding: widget.padding,
child: Tooltip( child: Tooltip(
waitDuration: Duration(milliseconds: 300),
message: widget.tooltip, message: widget.tooltip,
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,

View File

@@ -38,18 +38,16 @@ class PopupMenuChildrenItem<T> extends mod_menu.PopupMenuEntry<T> {
@override @override
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>> createState() => MyPopupMenuItemState<T, PopupMenuChildrenItem<T>> createState() =>
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>>(); MyPopupMenuItemState<T, PopupMenuChildrenItem<T>>(enabled?.value);
} }
class MyPopupMenuItemState<T, W extends PopupMenuChildrenItem<T>> class MyPopupMenuItemState<T, W extends PopupMenuChildrenItem<T>>
extends State<W> { extends State<W> {
RxBool enabled = true.obs; RxBool enabled = true.obs;
@override MyPopupMenuItemState(bool? e) {
void initState() { if (e != null) {
super.initState(); enabled.value = e;
if (widget.enabled != null) {
enabled.value = widget.enabled!.value;
} }
} }
@@ -65,7 +63,7 @@ class MyPopupMenuItemState<T, W extends PopupMenuChildrenItem<T>>
final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
TextStyle style = widget.textStyle ?? TextStyle style = widget.textStyle ??
popupMenuTheme.textStyle ?? popupMenuTheme.textStyle ??
theme.textTheme.subtitle1!; theme.textTheme.titleMedium!;
return Obx(() => mod_menu.PopupMenuButton<T>( return Obx(() => mod_menu.PopupMenuButton<T>(
enabled: enabled.value, enabled: enabled.value,
position: widget.position, position: widget.position,

View File

@@ -26,45 +26,42 @@ import './popup_menu.dart';
import './kb_layout_type_chooser.dart'; import './kb_layout_type_chooser.dart';
class ToolbarState { class ToolbarState {
late RxBool show;
late RxBool _pin; late RxBool _pin;
bool isShowInited = false;
RxBool show = false.obs;
ToolbarState() { ToolbarState() {
_pin = RxBool(false);
final s = bind.getLocalFlutterOption(k: kOptionRemoteMenubarState); final s = bind.getLocalFlutterOption(k: kOptionRemoteMenubarState);
if (s.isEmpty) { if (s.isEmpty) {
_initSet(false, false);
return; return;
} }
try { try {
final m = jsonDecode(s); final m = jsonDecode(s);
if (m == null) { if (m != null) {
_initSet(false, false); _pin = RxBool(m['pin'] ?? false);
} else {
_initSet(m['pin'] ?? false, m['pin'] ?? false);
} }
} catch (e) { } catch (e) {
debugPrint('Failed to decode toolbar state ${e.toString()}'); debugPrint('Failed to decode toolbar state ${e.toString()}');
_initSet(false, false);
} }
} }
_initSet(bool s, bool p) {
// Show remubar when connection is established.
show = RxBool(
bind.mainGetUserDefaultOption(key: kOptionCollapseToolbar) != 'Y');
_pin = RxBool(p);
}
bool get pin => _pin.value; bool get pin => _pin.value;
switchShow() async { switchShow(SessionID sessionId) async {
bind.sessionToggleOption(
sessionId: sessionId, value: kOptionCollapseToolbar);
show.value = !show.value; show.value = !show.value;
} }
setShow(bool v) async { initShow(SessionID sessionId) async {
if (show.value != v) { if (!isShowInited) {
show.value = v; show.value = !(await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionCollapseToolbar) ??
false);
isShowInited = true;
} }
} }
@@ -86,10 +83,6 @@ class ToolbarState {
bind.setLocalFlutterOption( bind.setLocalFlutterOption(
k: kOptionRemoteMenubarState, v: jsonEncode({'pin': _pin.value})); k: kOptionRemoteMenubarState, v: jsonEncode({'pin': _pin.value}));
} }
save() async {
await _savePin();
}
} }
class _ToolbarTheme { class _ToolbarTheme {
@@ -332,8 +325,8 @@ class RemoteToolbar extends StatefulWidget {
final String id; final String id;
final FFI ffi; final FFI ffi;
final ToolbarState state; final ToolbarState state;
final Function(Function(bool)) onEnterOrLeaveImageSetter; final Function(int, Function(bool)) onEnterOrLeaveImageSetter;
final VoidCallback onEnterOrLeaveImageCleaner; final Function(int) onEnterOrLeaveImageCleaner;
final Function(VoidCallback) setRemoteState; final Function(VoidCallback) setRemoteState;
RemoteToolbar({ RemoteToolbar({
@@ -379,7 +372,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
initState() { initState() {
super.initState(); super.initState();
Future.delayed(Duration.zero, () async { WidgetsBinding.instance.addPostFrameCallback((_) async {
_fractionX.value = double.tryParse(await bind.sessionGetOption( _fractionX.value = double.tryParse(await bind.sessionGetOption(
sessionId: widget.ffi.sessionId, sessionId: widget.ffi.sessionId,
arg: 'remote-menubar-drag-x') ?? arg: 'remote-menubar-drag-x') ??
@@ -393,7 +386,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
initialValue: 0, initialValue: 0,
); );
widget.onEnterOrLeaveImageSetter((enter) { widget.onEnterOrLeaveImageSetter(identityHashCode(this), (enter) {
if (enter) { if (enter) {
triggerAutoHide(); triggerAutoHide();
_isCursorOverImage = true; _isCursorOverImage = true;
@@ -413,12 +406,11 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
dispose() { dispose() {
super.dispose(); super.dispose();
widget.onEnterOrLeaveImageCleaner(); widget.onEnterOrLeaveImageCleaner(identityHashCode(this));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// No need to use future builder here.
return Align( return Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Obx(() => show.value child: Obx(() => show.value
@@ -444,10 +436,11 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
shadowColor: MyTheme.color(context).shadow, shadowColor: MyTheme.color(context).shadow,
borderRadius: borderRadius, borderRadius: borderRadius,
child: _DraggableShowHide( child: _DraggableShowHide(
id: widget.id,
sessionId: widget.ffi.sessionId, sessionId: widget.ffi.sessionId,
dragging: _dragging, dragging: _dragging,
fractionX: _fractionX, fractionX: _fractionX,
show: show, toolbarState: widget.state,
setFullscreen: _setFullscreen, setFullscreen: _setFullscreen,
setMinimize: _minimize, setMinimize: _minimize,
borderRadius: borderRadius, borderRadius: borderRadius,
@@ -460,8 +453,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
Widget _buildToolbar(BuildContext context) { Widget _buildToolbar(BuildContext context) {
final List<Widget> toolbarItems = []; final List<Widget> toolbarItems = [];
if (!isWebDesktop) {
toolbarItems.add(_PinMenu(state: widget.state)); toolbarItems.add(_PinMenu(state: widget.state));
if (!isWebDesktop) {
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi)); toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
} }
@@ -486,8 +479,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
setFullscreen: _setFullscreen, setFullscreen: _setFullscreen,
)); ));
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi)); toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
if (!isWeb) {
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi)); toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
if (!isWeb) {
toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi)); toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
} }
if (!isWeb) toolbarItems.add(_RecordMenu()); if (!isWeb) toolbarItems.add(_RecordMenu());
@@ -587,8 +580,8 @@ class _MobileActionMenu extends StatelessWidget {
return Obx(() => _IconMenuButton( return Obx(() => _IconMenuButton(
assetName: 'assets/actions_mobile.svg', assetName: 'assets/actions_mobile.svg',
tooltip: 'Mobile Actions', tooltip: 'Mobile Actions',
onPressed: () => onPressed: () => ffi.dialogManager.setMobileActionsOverlayVisible(
ffi.dialogManager.mobileActionsOverlayVisible.toggle(), !ffi.dialogManager.mobileActionsOverlayVisible.value),
color: ffi.dialogManager.mobileActionsOverlayVisible.isTrue color: ffi.dialogManager.mobileActionsOverlayVisible.isTrue
? _ToolbarTheme.blueColor ? _ToolbarTheme.blueColor
: _ToolbarTheme.inactiveColor, : _ToolbarTheme.inactiveColor,
@@ -643,15 +636,12 @@ class _MonitorMenu extends StatelessWidget {
} }
Widget buildMonitorSubmenuWidget(BuildContext context) { Widget buildMonitorSubmenuWidget(BuildContext context) {
final m = Provider.of<ImageModel>(context);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row(children: buildMonitorList(context, false)), Row(children: buildMonitorList(context, false)),
supportIndividualWindows && m.useTextureRender ? Divider() : Offstage(), supportIndividualWindows ? Divider() : Offstage(),
supportIndividualWindows && m.useTextureRender supportIndividualWindows ? chooseDisplayBehavior() : Offstage(),
? chooseDisplayBehavior()
: Offstage(),
], ],
); );
} }
@@ -665,7 +655,7 @@ class _MonitorMenu extends StatelessWidget {
onChanged: (value) async { onChanged: (value) async {
if (value == null) return; if (value == null) return;
await bind.sessionSetDisplaysAsIndividualWindows( await bind.sessionSetDisplaysAsIndividualWindows(
sessionId: ffi.sessionId, value: value ? 'Y' : ''); sessionId: ffi.sessionId, value: value ? 'Y' : 'N');
}, },
ffi: ffi, ffi: ffi,
child: Text(translate('Show displays as individual windows'))); child: Text(translate('Show displays as individual windows')));
@@ -737,10 +727,7 @@ class _MonitorMenu extends StatelessWidget {
for (int i = 0; i < pi.displays.length; i++) { for (int i = 0; i < pi.displays.length; i++) {
monitorList.add(buildMonitorButton(i)); monitorList.add(buildMonitorButton(i));
} }
final m = Provider.of<ImageModel>(context); if (supportIndividualWindows && pi.displays.length > 1) {
if (supportIndividualWindows &&
m.useTextureRender &&
pi.displays.length > 1) {
monitorList.add(buildMonitorButton(kAllDisplayValue)); monitorList.add(buildMonitorButton(kAllDisplayValue));
} }
return monitorList; return monitorList;
@@ -824,7 +811,6 @@ class _MonitorMenu extends StatelessWidget {
RxInt display = CurrentDisplayState.find(id); RxInt display = CurrentDisplayState.find(id);
if (display.value != i) { if (display.value != i) {
final isChooseDisplayToOpenInNewWindow = pi.isSupportMultiDisplay && final isChooseDisplayToOpenInNewWindow = pi.isSupportMultiDisplay &&
bind.mainGetUseTextureRender() &&
bind.sessionGetDisplaysAsIndividualWindows( bind.sessionGetDisplaysAsIndividualWindows(
sessionId: ffi.sessionId) == sessionId: ffi.sessionId) ==
'Y'; 'Y';
@@ -1047,11 +1033,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
FFI get ffi => widget.ffi; FFI get ffi => widget.ffi;
String get id => widget.id; String get id => widget.id;
@override
void initState() {
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_screenAdjustor.updateScreen(); _screenAdjustor.updateScreen();
@@ -1067,15 +1048,11 @@ class _DisplayMenuState extends State<_DisplayMenu> {
ffi: widget.ffi, ffi: widget.ffi,
screenAdjustor: _screenAdjustor, screenAdjustor: _screenAdjustor,
), ),
if (pi.isRustDeskIdd) if (showVirtualDisplayMenu(ffi))
_RustDeskVirtualDisplayMenu( _SubmenuButton(
id: widget.id,
ffi: widget.ffi,
),
if (pi.isAmyuniIdd)
_AmyuniVirtualDisplayMenu(
id: widget.id,
ffi: widget.ffi, ffi: widget.ffi,
menuChildren: getVirtualDisplayMenuChildren(ffi, id, null),
child: Text(translate("Virtual display")),
), ),
cursorToggles(), cursorToggles(),
Divider(), Divider(),
@@ -1297,7 +1274,9 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_getLocalResolutionWayland(); _getLocalResolutionWayland();
});
} }
Rect? scaledRect() { Rect? scaledRect() {
@@ -1574,155 +1553,6 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
} }
} }
class _RustDeskVirtualDisplayMenu extends StatefulWidget {
final String id;
final FFI ffi;
_RustDeskVirtualDisplayMenu({
Key? key,
required this.id,
required this.ffi,
}) : super(key: key);
@override
State<_RustDeskVirtualDisplayMenu> createState() =>
_RustDeskVirtualDisplayMenuState();
}
class _RustDeskVirtualDisplayMenuState
extends State<_RustDeskVirtualDisplayMenu> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
if (widget.ffi.ffiModel.pi.platform != kPeerPlatformWindows) {
return Offstage();
}
if (!widget.ffi.ffiModel.pi.isInstalled) {
return Offstage();
}
final virtualDisplays = widget.ffi.ffiModel.pi.RustDeskVirtualDisplays;
final privacyModeState = PrivacyModeState.find(widget.id);
final children = <Widget>[];
for (var i = 0; i < kMaxVirtualDisplayCount; i++) {
children.add(Obx(() => CkbMenuButton(
value: virtualDisplays.contains(i + 1),
onChanged: privacyModeState.isNotEmpty
? null
: (bool? value) async {
if (value != null) {
bind.sessionToggleVirtualDisplay(
sessionId: widget.ffi.sessionId,
index: i + 1,
on: value);
}
},
child: Text('${translate('Virtual display')} ${i + 1}'),
ffi: widget.ffi,
)));
}
children.add(Divider());
children.add(Obx(() => MenuButton(
onPressed: privacyModeState.isNotEmpty
? null
: () {
bind.sessionToggleVirtualDisplay(
sessionId: widget.ffi.sessionId,
index: kAllVirtualDisplay,
on: false);
},
ffi: widget.ffi,
child: Text(translate('Plug out all')),
)));
return _SubmenuButton(
ffi: widget.ffi,
menuChildren: children,
child: Text(translate("Virtual display")),
);
}
}
class _AmyuniVirtualDisplayMenu extends StatefulWidget {
final String id;
final FFI ffi;
_AmyuniVirtualDisplayMenu({
Key? key,
required this.id,
required this.ffi,
}) : super(key: key);
@override
State<_AmyuniVirtualDisplayMenu> createState() =>
_AmiyuniVirtualDisplayMenuState();
}
class _AmiyuniVirtualDisplayMenuState extends State<_AmyuniVirtualDisplayMenu> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
if (widget.ffi.ffiModel.pi.platform != kPeerPlatformWindows) {
return Offstage();
}
if (!widget.ffi.ffiModel.pi.isInstalled) {
return Offstage();
}
final count = widget.ffi.ffiModel.pi.amyuniVirtualDisplayCount;
final privacyModeState = PrivacyModeState.find(widget.id);
final children = <Widget>[
Obx(() => Row(
children: [
TextButton(
onPressed: privacyModeState.isNotEmpty || count == 0
? null
: () => bind.sessionToggleVirtualDisplay(
sessionId: widget.ffi.sessionId, index: 0, on: false),
child: Icon(Icons.remove),
),
Text(count.toString()),
TextButton(
onPressed: privacyModeState.isNotEmpty || count == 4
? null
: () => bind.sessionToggleVirtualDisplay(
sessionId: widget.ffi.sessionId, index: 0, on: true),
child: Icon(Icons.add),
),
],
)),
Divider(),
Obx(() => MenuButton(
onPressed: privacyModeState.isNotEmpty || count == 0
? null
: () {
bind.sessionToggleVirtualDisplay(
sessionId: widget.ffi.sessionId,
index: kAllVirtualDisplay,
on: false);
},
ffi: widget.ffi,
child: Text(translate('Plug out all')),
)),
];
return _SubmenuButton(
ffi: widget.ffi,
menuChildren: children,
child: Text(translate("Virtual display")),
);
}
}
class _KeyboardMenu extends StatelessWidget { class _KeyboardMenu extends StatelessWidget {
final String id; final String id;
final FFI ffi; final FFI ffi;
@@ -1756,6 +1586,7 @@ class _KeyboardMenu extends StatelessWidget {
viewMode(), viewMode(),
Divider(), Divider(),
...toolbarToggles(), ...toolbarToggles(),
...mobileActions(),
]); ]);
} }
@@ -1782,7 +1613,9 @@ class _KeyboardMenu extends StatelessWidget {
// If use flutter to grab keys, we can only use one mode. // If use flutter to grab keys, we can only use one mode.
// Map mode and Legacy mode, at least one of them is supported. // Map mode and Legacy mode, at least one of them is supported.
String? modeOnly; String? modeOnly;
if (isInputSourceFlutter) { // Keep both map and legacy mode on web at the moment.
// TODO: Remove legacy mode after web supports translate mode on web.
if (isInputSourceFlutter && isDesktop) {
if (bind.sessionIsKeyboardModeSupported( if (bind.sessionIsKeyboardModeSupported(
sessionId: ffi.sessionId, mode: kKeyMapMode)) { sessionId: ffi.sessionId, mode: kKeyMapMode)) {
modeOnly = kKeyMapMode; modeOnly = kKeyMapMode;
@@ -1886,12 +1719,47 @@ class _KeyboardMenu extends StatelessWidget {
if (value == null) return; if (value == null) return;
await bind.sessionToggleOption( await bind.sessionToggleOption(
sessionId: ffi.sessionId, value: kOptionToggleViewOnly); sessionId: ffi.sessionId, value: kOptionToggleViewOnly);
ffiModel.setViewOnly(id, value); final viewOnly = await bind.sessionGetToggleOption(
sessionId: ffi.sessionId, arg: kOptionToggleViewOnly);
ffiModel.setViewOnly(id, viewOnly ?? value);
} }
: null, : null,
ffi: ffi, ffi: ffi,
child: Text(translate('View Mode'))); child: Text(translate('View Mode')));
} }
mobileActions() {
if (pi.platform != kPeerPlatformAndroid) return [];
final enabled = versionCmp(pi.version, '1.2.7') >= 0;
if (!enabled) return [];
return [
Divider(),
MenuButton(
child: Text(translate('Back')),
onPressed: () => ffi.inputModel.onMobileBack(),
ffi: ffi),
MenuButton(
child: Text(translate('Home')),
onPressed: () => ffi.inputModel.onMobileHome(),
ffi: ffi),
MenuButton(
child: Text(translate('Apps')),
onPressed: () => ffi.inputModel.onMobileApps(),
ffi: ffi),
MenuButton(
child: Text(translate('Volume up')),
onPressed: () => ffi.inputModel.onMobileVolumeUp(),
ffi: ffi),
MenuButton(
child: Text(translate('Volume down')),
onPressed: () => ffi.inputModel.onMobileVolumeDown(),
ffi: ffi),
MenuButton(
child: Text(translate('Power')),
onPressed: () => ffi.inputModel.onMobilePower(),
ffi: ffi),
];
}
} }
class _ChatMenu extends StatefulWidget { class _ChatMenu extends StatefulWidget {
@@ -1913,6 +1781,9 @@ class _ChatMenuState extends State<_ChatMenu> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (isWeb) {
return buildTextChatButton();
} else {
return _IconSubmenuButton( return _IconSubmenuButton(
tooltip: 'Chat', tooltip: 'Chat',
key: chatButtonKey, key: chatButtonKey,
@@ -1922,25 +1793,37 @@ class _ChatMenuState extends State<_ChatMenu> {
hoverColor: _ToolbarTheme.hoverBlueColor, hoverColor: _ToolbarTheme.hoverBlueColor,
menuChildrenGetter: () => [textChat(), voiceCall()]); menuChildrenGetter: () => [textChat(), voiceCall()]);
} }
}
buildTextChatButton() {
return _IconMenuButton(
assetName: 'assets/message_24dp_5F6368.svg',
tooltip: 'Text chat',
key: chatButtonKey,
onPressed: _textChatOnPressed,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
);
}
textChat() { textChat() {
return MenuButton( return MenuButton(
child: Text(translate('Text chat')), child: Text(translate('Text chat')),
ffi: widget.ffi, ffi: widget.ffi,
onPressed: () { onPressed: _textChatOnPressed);
}
_textChatOnPressed() {
RenderBox? renderBox = RenderBox? renderBox =
chatButtonKey.currentContext?.findRenderObject() as RenderBox?; chatButtonKey.currentContext?.findRenderObject() as RenderBox?;
Offset? initPos; Offset? initPos;
if (renderBox != null) { if (renderBox != null) {
final pos = renderBox.localToGlobal(Offset.zero); final pos = renderBox.localToGlobal(Offset.zero);
initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight); initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight);
} }
widget.ffi.chatModel
widget.ffi.chatModel.changeCurrentKey( .changeCurrentKey(MessageKey(widget.ffi.id, ChatModel.clientModeID));
MessageKey(widget.ffi.id, ChatModel.clientModeID));
widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos); widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos);
});
} }
voiceCall() { voiceCall() {
@@ -1965,8 +1848,8 @@ class _VoiceCallMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
menuChildrenGetter() { menuChildrenGetter() {
final audioInput = final audioInput = AudioInput(
AudioInput(builder: (devices, currentDevice, setDevice) { builder: (devices, currentDevice, setDevice) {
return Column( return Column(
children: devices children: devices
.map((d) => RdoMenuButton<String>( .map((d) => RdoMenuButton<String>(
@@ -1986,7 +1869,10 @@ class _VoiceCallMenu extends StatelessWidget {
)) ))
.toList(), .toList(),
); );
}); },
isCm: false,
isVoiceCall: true,
);
return [ return [
audioInput, audioInput,
Divider(), Divider(),
@@ -2348,10 +2234,11 @@ class RdoMenuButton<T> extends StatelessWidget {
} }
class _DraggableShowHide extends StatefulWidget { class _DraggableShowHide extends StatefulWidget {
final String id;
final SessionID sessionId; final SessionID sessionId;
final RxDouble fractionX; final RxDouble fractionX;
final RxBool dragging; final RxBool dragging;
final RxBool show; final ToolbarState toolbarState;
final BorderRadius borderRadius; final BorderRadius borderRadius;
final Function(bool) setFullscreen; final Function(bool) setFullscreen;
@@ -2359,10 +2246,11 @@ class _DraggableShowHide extends StatefulWidget {
const _DraggableShowHide({ const _DraggableShowHide({
Key? key, Key? key,
required this.id,
required this.sessionId, required this.sessionId,
required this.fractionX, required this.fractionX,
required this.dragging, required this.dragging,
required this.show, required this.toolbarState,
required this.setFullscreen, required this.setFullscreen,
required this.setMinimize, required this.setMinimize,
required this.borderRadius, required this.borderRadius,
@@ -2378,6 +2266,8 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
double left = 0.0; double left = 0.0;
double right = 1.0; double right = 1.0;
RxBool get show => widget.toolbarState.show;
@override @override
initState() { initState() {
super.initState(); super.initState();
@@ -2446,15 +2336,33 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
); );
final isFullscreen = stateGlobal.fullscreen; final isFullscreen = stateGlobal.fullscreen;
const double iconSize = 20; const double iconSize = 20;
buttonWrapper(VoidCallback? onPressed, Widget child,
{Color hoverColor = _ToolbarTheme.blueColor}) {
final bgColor = buttonStyle.backgroundColor?.resolve({});
return TextButton(
onPressed: onPressed,
child: child,
style: buttonStyle.copyWith(
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.hovered)) {
return (bgColor ?? hoverColor).withOpacity(0.15);
}
return bgColor;
}),
),
);
}
final child = Row( final child = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDraggable(context), _buildDraggable(context),
Obx(() => TextButton( Obx(() => buttonWrapper(
onPressed: () { () {
widget.setFullscreen(!isFullscreen.value); widget.setFullscreen(!isFullscreen.value);
}, },
child: Tooltip( Tooltip(
message: translate( message: translate(
isFullscreen.isTrue ? 'Exit Fullscreen' : 'Fullscreen'), isFullscreen.isTrue ? 'Exit Fullscreen' : 'Fullscreen'),
child: Icon( child: Icon(
@@ -2465,11 +2373,12 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
), ),
), ),
)), )),
if (!isMacOS) Obx(() => Offstage( if (!isMacOS && !isWebDesktop)
Obx(() => Offstage(
offstage: isFullscreen.isFalse, offstage: isFullscreen.isFalse,
child: TextButton( child: buttonWrapper(
onPressed: () => widget.setMinimize(), widget.setMinimize,
child: Tooltip( Tooltip(
message: translate('Minimize'), message: translate('Minimize'),
child: Icon( child: Icon(
Icons.remove, Icons.remove,
@@ -2478,19 +2387,38 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
), ),
), ),
)), )),
TextButton( buttonWrapper(
onPressed: () => setState(() { () => setState(() {
widget.show.value = !widget.show.value; widget.toolbarState.switchShow(widget.sessionId);
}), }),
child: Obx((() => Tooltip( Obx((() => Tooltip(
message: translate( message:
widget.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'), translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
child: Icon( child: Icon(
widget.show.isTrue ? Icons.expand_less : Icons.expand_more, show.isTrue ? Icons.expand_less : Icons.expand_more,
size: iconSize, size: iconSize,
), ),
))), ))),
), ),
if (isWebDesktop)
Obx(() {
if (show.isTrue) {
return Offstage();
} else {
return buttonWrapper(
() => closeConnection(id: widget.id),
Tooltip(
message: translate('Close'),
child: Icon(
Icons.close,
size: iconSize,
color: _ToolbarTheme.redColor,
),
),
hoverColor: _ToolbarTheme.redColor,
).paddingOnly(left: iconSize / 2);
}
})
], ],
); );
return TextButtonTheme( return TextButtonTheme(

View File

@@ -227,11 +227,9 @@ typedef TabMenuBuilder = Widget Function(String key);
typedef LabelGetter = Rx<String> Function(String key); typedef LabelGetter = Rx<String> Function(String key);
/// [_lastClickTime], help to handle double click /// [_lastClickTime], help to handle double click
int _lastClickTime = int _lastClickTime = 0;
DateTime.now().millisecondsSinceEpoch - bind.getDoubleClickTime() - 1000;
// ignore: must_be_immutable class DesktopTab extends StatefulWidget {
class DesktopTab extends StatelessWidget {
final bool showLogo; final bool showLogo;
final bool showTitle; final bool showTitle;
final bool showMinimize; final bool showMinimize;
@@ -248,15 +246,12 @@ class DesktopTab extends StatelessWidget {
final Color? selectedTabBackgroundColor; final Color? selectedTabBackgroundColor;
final Color? unSelectedTabBackgroundColor; final Color? unSelectedTabBackgroundColor;
final Color? selectedBorderColor; final Color? selectedBorderColor;
final RxBool? blockTab;
final DesktopTabController controller; final DesktopTabController controller;
Rx<DesktopTabState> get state => controller.state;
final _scrollDebounce = Debouncer(delay: Duration(milliseconds: 50)); final _scrollDebounce = Debouncer(delay: Duration(milliseconds: 50));
late final DesktopTabType tabType;
late final bool isMainWindow;
final RxList<String> invisibleTabKeys = RxList.empty(); final RxList<String> invisibleTabKeys = RxList.empty();
DesktopTab({ DesktopTab({
@@ -277,38 +272,260 @@ class DesktopTab extends StatelessWidget {
this.selectedTabBackgroundColor, this.selectedTabBackgroundColor,
this.unSelectedTabBackgroundColor, this.unSelectedTabBackgroundColor,
this.selectedBorderColor, this.selectedBorderColor,
}) : super(key: key) { this.blockTab,
tabType = controller.tabType; }) : super(key: key);
isMainWindow = tabType == DesktopTabType.main ||
tabType == DesktopTabType.cm ||
tabType == DesktopTabType.install;
}
static RxString tablabelGetter(String peerId) { static RxString tablabelGetter(String peerId) {
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias'); final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
return RxString(getDesktopTabLabel(peerId, alias)); return RxString(getDesktopTabLabel(peerId, alias));
} }
@override
State<DesktopTab> createState() {
return _DesktopTabState();
}
}
// ignore: must_be_immutable
class _DesktopTabState extends State<DesktopTab>
with MultiWindowListener, WindowListener {
final _saveFrameDebounce = Debouncer(delay: Duration(seconds: 1));
Timer? _macOSCheckRestoreTimer;
int _macOSCheckRestoreCounter = 0;
bool get showLogo => widget.showLogo;
bool get showTitle => widget.showTitle;
bool get showMinimize => widget.showMinimize;
bool get showMaximize => widget.showMaximize;
bool get showClose => widget.showClose;
Widget Function(Widget pageView)? get pageViewBuilder =>
widget.pageViewBuilder;
TabMenuBuilder? get tabMenuBuilder => widget.tabMenuBuilder;
Widget? get tail => widget.tail;
Future<bool> Function()? get onWindowCloseButton =>
widget.onWindowCloseButton;
TabBuilder? get tabBuilder => widget.tabBuilder;
LabelGetter? get labelGetter => widget.labelGetter;
double? get maxLabelWidth => widget.maxLabelWidth;
Color? get selectedTabBackgroundColor => widget.selectedTabBackgroundColor;
Color? get unSelectedTabBackgroundColor =>
widget.unSelectedTabBackgroundColor;
Color? get selectedBorderColor => widget.selectedBorderColor;
RxBool? get blockTab => widget.blockTab;
DesktopTabController get controller => widget.controller;
RxList<String> get invisibleTabKeys => widget.invisibleTabKeys;
Debouncer get _scrollDebounce => widget._scrollDebounce;
Rx<DesktopTabState> get state => controller.state;
DesktopTabType get tabType => controller.tabType;
bool get isMainWindow =>
tabType == DesktopTabType.main ||
tabType == DesktopTabType.cm ||
tabType == DesktopTabType.install;
_DesktopTabState() : super();
static RxString tablabelGetter(String peerId) {
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
return RxString(getDesktopTabLabel(peerId, alias));
}
@override
void initState() {
super.initState();
DesktopMultiWindow.addListener(this);
windowManager.addListener(this);
Future.delayed(Duration(milliseconds: 500), () {
if (isMainWindow) {
windowManager.isMaximized().then((maximized) {
if (stateGlobal.isMaximized.value != maximized) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => setState(() => stateGlobal.setMaximized(maximized)));
}
});
} else {
final wc = WindowController.fromWindowId(kWindowId!);
wc.isMaximized().then((maximized) {
debugPrint("isMaximized $maximized");
if (stateGlobal.isMaximized.value != maximized) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => setState(() => stateGlobal.setMaximized(maximized)));
}
});
}
});
}
@override
void dispose() {
DesktopMultiWindow.removeListener(this);
windowManager.removeListener(this);
_macOSCheckRestoreTimer?.cancel();
super.dispose();
}
void _setMaximized(bool maximize) {
stateGlobal.setMaximized(maximize);
_saveFrameDebounce.call(_saveFrame);
setState(() {});
}
@override
void onWindowFocus() {
stateGlobal.isFocused.value = true;
}
@override
void onWindowBlur() {
stateGlobal.isFocused.value = false;
}
@override
void onWindowMinimize() {
stateGlobal.setMinimized(true);
stateGlobal.setMaximized(false);
super.onWindowMinimize();
}
@override
void onWindowMaximize() {
stateGlobal.setMinimized(false);
_setMaximized(true);
super.onWindowMaximize();
}
@override
void onWindowUnmaximize() {
stateGlobal.setMinimized(false);
_setMaximized(false);
super.onWindowUnmaximize();
}
_saveFrame() async {
if (tabType == DesktopTabType.main) {
await saveWindowPosition(WindowType.Main);
} else if (kWindowType != null && kWindowId != null) {
await saveWindowPosition(kWindowType!, windowId: kWindowId);
}
}
@override
void onWindowMoved() {
_saveFrameDebounce.call(_saveFrame);
super.onWindowMoved();
}
@override
void onWindowResized() {
_saveFrameDebounce.call(_saveFrame);
super.onWindowMoved();
}
@override
void onWindowClose() async {
mainWindowClose() async => await windowManager.hide();
notMainWindowClose(WindowController windowController) async {
if (controller.length != 0) {
debugPrint("close not empty multiwindow from taskbar");
if (isWindows) {
await windowController.show();
await windowController.focus();
final res = await onWindowCloseButton?.call() ?? true;
if (!res) return;
}
controller.clear();
}
await windowController.hide();
await rustDeskWinManager
.call(WindowType.Main, kWindowEventHide, {"id": kWindowId!});
}
macOSWindowClose(
Future<bool> Function() checkFullscreen,
Future<void> Function() closeFunc,
) async {
_macOSCheckRestoreCounter = 0;
_macOSCheckRestoreTimer =
Timer.periodic(Duration(milliseconds: 30), (timer) async {
_macOSCheckRestoreCounter++;
if (!await checkFullscreen() || _macOSCheckRestoreCounter >= 30) {
_macOSCheckRestoreTimer?.cancel();
_macOSCheckRestoreTimer = null;
Timer(Duration(milliseconds: 700), () async => await closeFunc());
}
});
}
// hide window on close
if (isMainWindow) {
if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) {
await rustDeskWinManager.unregisterActiveWindow(kMainWindowId);
}
// macOS specific workaround, the window is not hiding when in fullscreen.
if (isMacOS && await windowManager.isFullScreen()) {
await windowManager.setFullScreen(false);
await macOSWindowClose(
() async => await windowManager.isFullScreen(),
mainWindowClose,
);
} else {
await mainWindowClose();
}
} else {
// it's safe to hide the subwindow
final controller = WindowController.fromWindowId(kWindowId!);
if (isMacOS) {
// onWindowClose() maybe called multiple times because of loopCloseWindow() in remote_tab_page.dart.
// use ??= to make sure the value is set on first call.
if (await onWindowCloseButton?.call() ?? true) {
if (await controller.isFullScreen()) {
await controller.setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false);
await macOSWindowClose(
() async => await controller.isFullScreen(),
() async => await notMainWindowClose(controller),
);
} else {
await notMainWindowClose(controller);
}
}
} else {
await notMainWindowClose(controller);
}
}
super.onWindowClose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column(children: [ return Column(children: [
Obx(() => Offstage( Obx(() {
offstage: !stateGlobal.showTabBar.isTrue || if (stateGlobal.showTabBar.isTrue &&
(kUseCompatibleUiMode && isHideSingleItem()), !(kUseCompatibleUiMode && isHideSingleItem())) {
child: SizedBox( final showBottomDivider = _showTabBarBottomDivider(tabType);
return SizedBox(
height: _kTabBarHeight, height: _kTabBarHeight,
child: Column( child: Column(
children: [ children: [
SizedBox( SizedBox(
height: _kTabBarHeight - 1, height:
showBottomDivider ? _kTabBarHeight - 1 : _kTabBarHeight,
child: _buildBar(), child: _buildBar(),
), ),
if (showBottomDivider)
const Divider( const Divider(
height: 1, height: 1,
), ),
], ],
), ),
))), );
} else {
return Offstage();
}
}),
Expanded( Expanded(
child: pageViewBuilder != null child: pageViewBuilder != null
? pageViewBuilder!(_buildPageView()) ? pageViewBuilder!(_buildPageView())
@@ -317,19 +534,15 @@ class DesktopTab extends StatelessWidget {
} }
Widget _buildBlock({required Widget child}) { Widget _buildBlock({required Widget child}) {
if (tabType != DesktopTabType.main) { if (blockTab != null) {
return child;
}
return buildRemoteBlock( return buildRemoteBlock(
child: child, child: child,
use: () async { block: blockTab!,
var access_mode = await bind.mainGetOption(key: kOptionAccessMode); use: canBeBlocked,
var option = option2bool( mask: tabType == DesktopTabType.main);
kOptionAllowRemoteConfigModification, } else {
await bind.mainGetOption( return child;
key: kOptionAllowRemoteConfigModification)); }
return access_mode == 'view' || (access_mode.isEmpty && !option);
});
} }
List<Widget> _tabWidgets = []; List<Widget> _tabWidgets = [];
@@ -339,6 +552,13 @@ class DesktopTab extends StatelessWidget {
controller: state.value.pageController, controller: state.value.pageController,
physics: NeverScrollableScrollPhysics(), physics: NeverScrollableScrollPhysics(),
children: () { children: () {
if (DesktopTabType.cm == tabType) {
// Fix when adding a new tab still showing closed tabs with the same peer id, which would happen after the DesktopTab was stateful.
return state.value.tabs.map((tab) {
return tab.page;
}).toList();
}
/// to-do refactor, separate connection state and UI state for remote session. /// to-do refactor, separate connection state and UI state for remote session.
/// [workaround] PageView children need an immutable list, after it has been passed into PageView /// [workaround] PageView children need an immutable list, after it has been passed into PageView
final tabLen = state.value.tabs.length; final tabLen = state.value.tabs.length;
@@ -397,6 +617,8 @@ class DesktopTab extends StatelessWidget {
: null, : null,
onPanStart: (_) => startDragging(isMainWindow), onPanStart: (_) => startDragging(isMainWindow),
onPanCancel: () { onPanCancel: () {
// We want to disable dragging of the tab area in the tab bar.
// Disable dragging is needed because macOS handles dragging by default.
if (isMacOS) { if (isMacOS) {
setMovable(isMainWindow, false); setMovable(isMainWindow, false);
} }
@@ -464,7 +686,6 @@ class DesktopTab extends StatelessWidget {
// hide simulated action buttons when we in compatible ui mode, because of reusing system title bar. // hide simulated action buttons when we in compatible ui mode, because of reusing system title bar.
WindowActionPanel( WindowActionPanel(
isMainWindow: isMainWindow, isMainWindow: isMainWindow,
tabType: tabType,
state: state, state: state,
tabController: controller, tabController: controller,
invisibleTabKeys: invisibleTabKeys, invisibleTabKeys: invisibleTabKeys,
@@ -482,7 +703,6 @@ class DesktopTab extends StatelessWidget {
class WindowActionPanel extends StatefulWidget { class WindowActionPanel extends StatefulWidget {
final bool isMainWindow; final bool isMainWindow;
final DesktopTabType tabType;
final Rx<DesktopTabState> state; final Rx<DesktopTabState> state;
final DesktopTabController tabController; final DesktopTabController tabController;
@@ -498,7 +718,6 @@ class WindowActionPanel extends StatefulWidget {
const WindowActionPanel( const WindowActionPanel(
{Key? key, {Key? key,
required this.isMainWindow, required this.isMainWindow,
required this.tabType,
required this.state, required this.state,
required this.tabController, required this.tabController,
required this.invisibleTabKeys, required this.invisibleTabKeys,
@@ -516,180 +735,7 @@ class WindowActionPanel extends StatefulWidget {
} }
} }
class WindowActionPanelState extends State<WindowActionPanel> class WindowActionPanelState extends State<WindowActionPanel> {
with MultiWindowListener, WindowListener {
final _saveFrameDebounce = Debouncer(delay: Duration(seconds: 1));
Timer? _macOSCheckRestoreTimer;
int _macOSCheckRestoreCounter = 0;
@override
void initState() {
super.initState();
DesktopMultiWindow.addListener(this);
windowManager.addListener(this);
Future.delayed(Duration(milliseconds: 500), () {
if (widget.isMainWindow) {
windowManager.isMaximized().then((maximized) {
if (stateGlobal.isMaximized.value != maximized) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => setState(() => stateGlobal.setMaximized(maximized)));
}
});
} else {
final wc = WindowController.fromWindowId(kWindowId!);
wc.isMaximized().then((maximized) {
debugPrint("isMaximized $maximized");
if (stateGlobal.isMaximized.value != maximized) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => setState(() => stateGlobal.setMaximized(maximized)));
}
});
}
});
}
@override
void dispose() {
DesktopMultiWindow.removeListener(this);
windowManager.removeListener(this);
_macOSCheckRestoreTimer?.cancel();
super.dispose();
}
void _setMaximized(bool maximize) {
stateGlobal.setMaximized(maximize);
_saveFrameDebounce.call(_saveFrame);
setState(() {});
}
@override
void onWindowFocus() {
stateGlobal.isFocused.value = true;
}
@override
void onWindowBlur() {
stateGlobal.isFocused.value = false;
}
@override
void onWindowMinimize() {
stateGlobal.setMinimized(true);
stateGlobal.setMaximized(false);
super.onWindowMinimize();
}
@override
void onWindowMaximize() {
stateGlobal.setMinimized(false);
_setMaximized(true);
super.onWindowMaximize();
}
@override
void onWindowUnmaximize() {
stateGlobal.setMinimized(false);
_setMaximized(false);
super.onWindowUnmaximize();
}
_saveFrame() async {
if (widget.tabType == DesktopTabType.main) {
await saveWindowPosition(WindowType.Main);
} else if (kWindowType != null && kWindowId != null) {
await saveWindowPosition(kWindowType!, windowId: kWindowId);
}
}
@override
void onWindowMoved() {
_saveFrameDebounce.call(_saveFrame);
super.onWindowMoved();
}
@override
void onWindowResized() {
_saveFrameDebounce.call(_saveFrame);
super.onWindowMoved();
}
@override
void onWindowClose() async {
mainWindowClose() async => await windowManager.hide();
notMainWindowClose(WindowController controller) async {
if (widget.tabController.length != 0) {
debugPrint("close not empty multiwindow from taskbar");
if (isWindows) {
await controller.show();
await controller.focus();
final res = await widget.onClose?.call() ?? true;
if (!res) return;
}
widget.tabController.clear();
}
await controller.hide();
await rustDeskWinManager
.call(WindowType.Main, kWindowEventHide, {"id": kWindowId!});
}
macOSWindowClose(
Future<bool> Function() checkFullscreen,
Future<void> Function() closeFunc,
) async {
_macOSCheckRestoreCounter = 0;
_macOSCheckRestoreTimer =
Timer.periodic(Duration(milliseconds: 30), (timer) async {
_macOSCheckRestoreCounter++;
if (!await checkFullscreen() || _macOSCheckRestoreCounter >= 30) {
_macOSCheckRestoreTimer?.cancel();
_macOSCheckRestoreTimer = null;
Timer(Duration(milliseconds: 700), () async => await closeFunc());
}
});
}
// hide window on close
if (widget.isMainWindow) {
if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) {
await rustDeskWinManager.unregisterActiveWindow(kMainWindowId);
}
// macOS specific workaround, the window is not hiding when in fullscreen.
if (isMacOS && await windowManager.isFullScreen()) {
await windowManager.setFullScreen(false);
await macOSWindowClose(
() async => await windowManager.isFullScreen(),
mainWindowClose,
);
} else {
await mainWindowClose();
}
} else {
// it's safe to hide the subwindow
final controller = WindowController.fromWindowId(kWindowId!);
if (isMacOS) {
// onWindowClose() maybe called multiple times because of loopCloseWindow() in remote_tab_page.dart.
// use ??= to make sure the value is set on first call.
if (await widget.onClose?.call() ?? true) {
if (await controller.isFullScreen()) {
await controller.setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false);
await macOSWindowClose(
() async => await controller.isFullScreen(),
() async => await notMainWindowClose(controller),
);
} else {
await notMainWindowClose(controller);
}
}
} else {
await notMainWindowClose(controller);
}
}
super.onWindowClose();
}
bool showTabDowndown() { bool showTabDowndown() {
return widget.tabController.state.value.tabs.length > 1 && return widget.tabController.state.value.tabs.length > 1 &&
(widget.tabController.tabType == DesktopTabType.remoteScreen || (widget.tabController.tabType == DesktopTabType.remoteScreen ||
@@ -710,22 +756,22 @@ class WindowActionPanelState extends State<WindowActionPanel>
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Obx(() => Offstage( Obx(() {
offstage: if (showTabDowndown() && existingInvisibleTab().isNotEmpty) {
!(showTabDowndown() && existingInvisibleTab().isNotEmpty), return _TabDropDownButton(
child: _TabDropDownButton(
controller: widget.tabController, controller: widget.tabController,
labelGetter: widget.labelGetter, labelGetter: widget.labelGetter,
tabkeys: existingInvisibleTab()), tabkeys: existingInvisibleTab());
)), } else {
Offstage(offstage: widget.tail == null, child: widget.tail), return Offstage();
Offstage( }
offstage: kUseCompatibleUiMode, }),
child: Row( if (widget.tail != null) widget.tail!,
if (!kUseCompatibleUiMode)
Row(
children: [ children: [
Offstage( if (widget.showMinimize && !isMacOS)
offstage: !widget.showMinimize || isMacOS, ActionIcon(
child: ActionIcon(
message: 'Minimize', message: 'Minimize',
icon: IconFont.min, icon: IconFont.min,
onTap: () { onTap: () {
@@ -736,10 +782,9 @@ class WindowActionPanelState extends State<WindowActionPanel>
} }
}, },
isClose: false, isClose: false,
)), ),
Offstage( if (widget.showMaximize && !isMacOS)
offstage: !widget.showMaximize || isMacOS, Obx(() => ActionIcon(
child: Obx(() => ActionIcon(
message: stateGlobal.isMaximized.isTrue message: stateGlobal.isMaximized.isTrue
? 'Restore' ? 'Restore'
: 'Maximize', : 'Maximize',
@@ -750,10 +795,9 @@ class WindowActionPanelState extends State<WindowActionPanel>
? null ? null
: _toggleMaximize, : _toggleMaximize,
isClose: false, isClose: false,
))), )),
Offstage( if (widget.showClose && !isMacOS)
offstage: !widget.showClose || isMacOS, ActionIcon(
child: ActionIcon(
message: 'Close', message: 'Close',
icon: IconFont.close, icon: IconFont.close,
onTap: () async { onTap: () async {
@@ -772,10 +816,9 @@ class WindowActionPanelState extends State<WindowActionPanel>
} }
}, },
isClose: true, isClose: true,
)) )
], ],
), ),
),
], ],
); );
} }
@@ -1128,7 +1171,10 @@ class _TabState extends State<_Tab> with RestorationMixin {
child: Row( child: Row(
children: [ children: [
SizedBox( SizedBox(
height: _kTabBarHeight, // _kTabBarHeight also displays normally
height: _showTabBarBottomDivider(widget.tabType)
? _kTabBarHeight - 1
: _kTabBarHeight,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@@ -1180,9 +1226,9 @@ class _CloseButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
width: _kIconSize, width: _kIconSize,
child: Offstage( child: () {
offstage: !visible, if (visible) {
child: InkWell( return InkWell(
hoverColor: MyTheme.tabbar(context).closeHoverColor, hoverColor: MyTheme.tabbar(context).closeHoverColor,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: () => onClose(), onTap: () => onClose(),
@@ -1193,8 +1239,12 @@ class _CloseButton extends StatelessWidget {
? MyTheme.tabbar(context).selectedIconColor ? MyTheme.tabbar(context).selectedIconColor
: MyTheme.tabbar(context).unSelectedIconColor, : MyTheme.tabbar(context).unSelectedIconColor,
), ),
), );
)).paddingOnly(left: 10); } else {
return Offstage();
}
}())
.paddingOnly(left: 10);
} }
} }
@@ -1223,13 +1273,7 @@ class ActionIcon extends StatefulWidget {
} }
class _ActionIconState extends State<ActionIcon> { class _ActionIconState extends State<ActionIcon> {
var hover = false.obs; final hover = false.obs;
@override
void initState() {
super.initState();
hover.value = false;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1348,10 +1392,10 @@ class _TabDropDownButtonState extends State<_TabDropDownButton> {
child: InkWell(child: Text(label)), child: InkWell(child: Text(label)),
), ),
Obx( Obx(
() => Offstage( () {
offstage: !(tabInfo?.onTabCloseButton != null && if (tabInfo?.onTabCloseButton != null &&
menuHover.value), menuHover.value) {
child: InkWell( return InkWell(
onTap: () { onTap: () {
tabInfo?.onTabCloseButton?.call(); tabInfo?.onTabCloseButton?.call();
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {
@@ -1366,9 +1410,12 @@ class _TabDropDownButtonState extends State<_TabDropDownButton> {
setState(() => btnHover.value = false), setState(() => btnHover.value = false),
child: Icon(Icons.close, child: Icon(Icons.close,
color: color:
btnHover.value ? Colors.red : null))), btnHover.value ? Colors.red : null)));
} else {
return Offstage();
}
},
), ),
)
], ],
), ),
), ),
@@ -1380,6 +1427,10 @@ class _TabDropDownButtonState extends State<_TabDropDownButton> {
} }
} }
bool _showTabBarBottomDivider(DesktopTabType tabType) {
return tabType == DesktopTabType.main || tabType == DesktopTabType.install;
}
class TabbarTheme extends ThemeExtension<TabbarTheme> { class TabbarTheme extends ThemeExtension<TabbarTheme> {
final Color? selectedTabIconColor; final Color? selectedTabIconColor;
final Color? unSelectedTabIconColor; final Color? unSelectedTabIconColor;

View File

@@ -6,6 +6,7 @@ import 'package:bot_toast/bot_toast.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/overlay.dart';
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
import 'package:flutter_hbb/desktop/pages/install_page.dart'; import 'package:flutter_hbb/desktop/pages/install_page.dart';
import 'package:flutter_hbb/desktop/pages/server_page.dart'; import 'package:flutter_hbb/desktop/pages/server_page.dart';
@@ -35,6 +36,7 @@ WindowType? kWindowType;
late List<String> kBootArgs; late List<String> kBootArgs;
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
earlyAssert();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
debugPrint("launch args: $args"); debugPrint("launch args: $args");
@@ -95,7 +97,9 @@ Future<void> main(List<String> args) async {
desktopType = DesktopType.main; desktopType = DesktopType.main;
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
windowManager.setPreventClose(true); windowManager.setPreventClose(true);
windowManager.setMovable(false); if (isMacOS) {
disableWindowMovable(kWindowId);
}
runMainApp(true); runMainApp(true);
} }
} }
@@ -154,10 +158,11 @@ void runMobileApp() async {
await initEnv(kAppTypeMain); await initEnv(kAppTypeMain);
if (isAndroid) androidChannelInit(); if (isAndroid) androidChannelInit();
if (isAndroid) platformFFI.syncAndroidServiceAppDirConfigPath(); if (isAndroid) platformFFI.syncAndroidServiceAppDirConfigPath();
draggablePositions.load();
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]); await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
gFFI.userModel.refreshCurrentUser(); gFFI.userModel.refreshCurrentUser();
runApp(App()); runApp(App());
if (!isWeb) await initUniLinks(); await initUniLinks();
} }
void runMultiWindow( void runMultiWindow(
@@ -168,10 +173,13 @@ void runMultiWindow(
final title = getWindowName(); final title = getWindowName();
// set prevent close to true, we handle close event manually // set prevent close to true, we handle close event manually
WindowController.fromWindowId(kWindowId!).setPreventClose(true); WindowController.fromWindowId(kWindowId!).setPreventClose(true);
WindowController.fromWindowId(kWindowId!).setMovable(false); if (isMacOS) {
disableWindowMovable(kWindowId);
}
late Widget widget; late Widget widget;
switch (appType) { switch (appType) {
case kAppTypeDesktopRemote: case kAppTypeDesktopRemote:
draggablePositions.load();
widget = DesktopRemoteScreen( widget = DesktopRemoteScreen(
params: argument, params: argument,
); );
@@ -253,7 +261,7 @@ showCmWindow({bool isStartup = false}) async {
WindowOptions windowOptions = getHiddenTitleBarWindowOptions( WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat, alwaysOnTop: true); size: kConnectionManagerWindowSizeClosedChat, alwaysOnTop: true);
await windowManager.waitUntilReadyToShow(windowOptions, null); await windowManager.waitUntilReadyToShow(windowOptions, null);
bind.mainHideDocker(); bind.mainHideDock();
await Future.wait([ await Future.wait([
windowManager.show(), windowManager.show(),
windowManager.focus(), windowManager.focus(),
@@ -281,14 +289,14 @@ hideCmWindow({bool isStartup = false}) async {
size: kConnectionManagerWindowSizeClosedChat); size: kConnectionManagerWindowSizeClosedChat);
windowManager.setOpacity(0); windowManager.setOpacity(0);
await windowManager.waitUntilReadyToShow(windowOptions, null); await windowManager.waitUntilReadyToShow(windowOptions, null);
bind.mainHideDocker(); bind.mainHideDock();
await windowManager.minimize(); await windowManager.minimize();
await windowManager.hide(); await windowManager.hide();
_isCmReadyToShow = true; _isCmReadyToShow = true;
} else if (_isCmReadyToShow) { } else if (_isCmReadyToShow) {
if (await windowManager.getOpacity() != 0) { if (await windowManager.getOpacity() != 0) {
await windowManager.setOpacity(0); await windowManager.setOpacity(0);
bind.mainHideDocker(); bind.mainHideDock();
await windowManager.minimize(); await windowManager.minimize();
await windowManager.hide(); await windowManager.hide();
} }
@@ -365,7 +373,7 @@ class App extends StatefulWidget {
State<App> createState() => _AppState(); State<App> createState() => _AppState();
} }
class _AppState extends State<App> { class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -389,6 +397,34 @@ class _AppState extends State<App> {
bind.mainChangeTheme(dark: to.toShortString()); bind.mainChangeTheme(dark: to.toShortString());
} }
}; };
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateOrientation());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
_updateOrientation();
}
void _updateOrientation() {
if (isDesktop) return;
// Don't use `MediaQuery.of(context).orientation` in `didChangeMetrics()`,
// my test (Flutter 3.19.6, Android 14) is always the reverse value.
// https://github.com/flutter/flutter/issues/60899
// stateGlobal.isPortrait.value =
// MediaQuery.of(context).orientation == Orientation.portrait;
final orientation = View.of(context).physicalSize.aspectRatio > 1
? Orientation.landscape
: Orientation.portrait;
stateGlobal.isPortrait.value = orientation == Orientation.portrait;
} }
@override @override
@@ -409,7 +445,9 @@ class _AppState extends State<App> {
child: GetMaterialApp( child: GetMaterialApp(
navigatorKey: globalKey, navigatorKey: globalKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'RustDesk', title: isWeb
? '${bind.mainGetAppNameSync()} Web Client V2 (Preview)'
: bind.mainGetAppNameSync(),
theme: MyTheme.lightTheme, theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme, darkTheme: MyTheme.darkTheme,
themeMode: MyTheme.currentThemeMode(), themeMode: MyTheme.currentThemeMode(),
@@ -440,7 +478,8 @@ class _AppState extends State<App> {
: (context, child) { : (context, child) {
child = _keepScaleBuilder(context, child); child = _keepScaleBuilder(context, child);
child = botToastBuilder(context, child); child = botToastBuilder(context, child);
if (isDesktop && desktopType == DesktopType.main) { if ((isDesktop && desktopType == DesktopType.main) ||
isWebDesktop) {
child = keyListenerBuilder(context, child); child = keyListenerBuilder(context, child);
} }
if (isLinux) { if (isLinux) {
@@ -468,7 +507,7 @@ _registerEventHandler() {
platformFFI.registerEventHandler('theme', 'theme', (evt) async { platformFFI.registerEventHandler('theme', 'theme', (evt) async {
String? dark = evt['dark']; String? dark = evt['dark'];
if (dark != null) { if (dark != null) {
MyTheme.changeDarkMode(MyTheme.themeModeFromString(dark)); await MyTheme.changeDarkMode(MyTheme.themeModeFromString(dark));
} }
}); });
platformFFI.registerEventHandler('language', 'language', (_) async { platformFFI.registerEventHandler('language', 'language', (_) async {

View File

@@ -3,25 +3,23 @@ import 'dart:async';
import 'package:auto_size_text_field/auto_size_text_field.dart'; import 'package:auto_size_text_field/auto_size_text_field.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_model.dart';
import '../../common.dart'; import '../../common.dart';
import '../../common/widgets/login.dart';
import '../../common/widgets/peer_tab_page.dart'; import '../../common/widgets/peer_tab_page.dart';
import '../../common/widgets/autocomplete.dart'; import '../../common/widgets/autocomplete.dart';
import '../../consts.dart'; import '../../consts.dart';
import '../../models/model.dart'; import '../../models/model.dart';
import '../../models/platform_model.dart'; import '../../models/platform_model.dart';
import 'home_page.dart'; import 'home_page.dart';
import 'scan_page.dart';
import 'settings_page.dart';
/// Connection page for connecting to a remote peer. /// Connection page for connecting to a remote peer.
class ConnectionPage extends StatefulWidget implements PageShape { class ConnectionPage extends StatefulWidget implements PageShape {
ConnectionPage({Key? key}) : super(key: key); ConnectionPage({Key? key, required this.appBarActions}) : super(key: key);
@override @override
final icon = const Icon(Icons.connected_tv); final icon = const Icon(Icons.connected_tv);
@@ -30,7 +28,7 @@ class ConnectionPage extends StatefulWidget implements PageShape {
final title = translate("Connection"); final title = translate("Connection");
@override @override
final appBarActions = isWeb ? <Widget>[const WebMenu()] : <Widget>[]; final List<Widget> appBarActions;
@override @override
State<ConnectionPage> createState() => _ConnectionPageState(); State<ConnectionPage> createState() => _ConnectionPageState();
@@ -50,33 +48,43 @@ class _ConnectionPageState extends State<ConnectionPage> {
bool isPeersLoaded = false; bool isPeersLoaded = false;
StreamSubscription? _uniLinksSubscription; StreamSubscription? _uniLinksSubscription;
_ConnectionPageState() {
if (!isWeb) _uniLinksSubscription = listenUniLinks();
_idController.addListener(() {
_idEmpty.value = _idController.text.isEmpty;
});
Get.put<IDTextEditingController>(_idController);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (!isWeb) _uniLinksSubscription = listenUniLinks();
if (_idController.text.isEmpty) { if (_idController.text.isEmpty) {
() async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId(); final lastRemoteId = await bind.mainGetLastRemoteId();
if (lastRemoteId != _idController.id) { if (lastRemoteId != _idController.id) {
setState(() { setState(() {
_idController.id = lastRemoteId; _idController.id = lastRemoteId;
}); });
} }
}(); });
} }
if (isAndroid) { if (isAndroid) {
if (!bind.isCustomClient()) { if (!bind.isCustomClient()) {
platformFFI.registerEventHandler(
kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish,
(Map<String, dynamic> evt) async {
if (evt['url'] is String) {
setState(() {
_updateUrl = evt['url'];
});
}
});
Timer(const Duration(seconds: 1), () async { Timer(const Duration(seconds: 1), () async {
_updateUrl = await bind.mainGetSoftwareUpdateUrl(); bind.mainGetSoftwareUpdateUrl();
if (_updateUrl.isNotEmpty) setState(() {});
}); });
} }
} }
_idController.addListener(() {
_idEmpty.value = _idController.text.isEmpty;
});
Get.put<IDTextEditingController>(_idController);
} }
@override @override
@@ -204,6 +212,8 @@ class _ConnectionPageState extends State<ConnectionPage> {
FocusNode fieldFocusNode, FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted) { VoidCallback onFieldSubmitted) {
fieldTextEditingController.text = _idController.text; fieldTextEditingController.text = _idController.text;
Get.put<TextEditingController>(
fieldTextEditingController);
fieldFocusNode.addListener(() async { fieldFocusNode.addListener(() async {
_idEmpty.value = _idEmpty.value =
fieldTextEditingController.text.isEmpty; fieldTextEditingController.text.isEmpty;
@@ -250,6 +260,9 @@ class _ConnectionPageState extends State<ConnectionPage> {
), ),
), ),
inputFormatters: [IDTextInputFormatter()], inputFormatters: [IDTextInputFormatter()],
onSubmitted: (_) {
onConnect();
},
); );
}, },
onSelected: (option) { onSelected: (option) {
@@ -339,9 +352,15 @@ class _ConnectionPageState extends State<ConnectionPage> {
), ),
), ),
); );
final child = Column(children: [
if (isWebDesktop)
getConnectionPageTitle(context, true)
.marginOnly(bottom: 10, top: 15, left: 12),
w
]);
return Align( return Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Container(constraints: kMobilePageConstraints, child: w)); child: Container(constraints: kMobilePageConstraints, child: child));
} }
@override @override
@@ -351,76 +370,13 @@ class _ConnectionPageState extends State<ConnectionPage> {
if (Get.isRegistered<IDTextEditingController>()) { if (Get.isRegistered<IDTextEditingController>()) {
Get.delete<IDTextEditingController>(); Get.delete<IDTextEditingController>();
} }
if (Get.isRegistered<TextEditingController>()) {
Get.delete<TextEditingController>();
}
if (!bind.isCustomClient()) {
platformFFI.unregisterEventHandler(
kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish);
}
super.dispose(); super.dispose();
} }
} }
class WebMenu extends StatefulWidget {
const WebMenu({Key? key}) : super(key: key);
@override
State<WebMenu> createState() => _WebMenuState();
}
class _WebMenuState extends State<WebMenu> {
@override
Widget build(BuildContext context) {
Provider.of<FfiModel>(context);
return PopupMenuButton<String>(
tooltip: "",
icon: const Icon(Icons.more_vert),
itemBuilder: (context) {
return (isIOS
? [
const PopupMenuItem(
value: "scan",
child: Icon(Icons.qr_code_scanner, color: Colors.black),
)
]
: <PopupMenuItem<String>>[]) +
[
PopupMenuItem(
value: "server",
child: Text(translate('ID/Relay Server')),
)
] +
[
PopupMenuItem(
value: "login",
child: Text(gFFI.userModel.userName.value.isEmpty
? translate("Login")
: '${translate("Logout")} (${gFFI.userModel.userName.value})'),
)
] +
[
PopupMenuItem(
value: "about",
child: Text('${translate('About')} RustDesk'),
)
];
},
onSelected: (value) {
if (value == 'server') {
showServerSettings(gFFI.dialogManager);
}
if (value == 'about') {
showAbout(gFFI.dialogManager);
}
if (value == 'login') {
if (gFFI.userModel.userName.value.isEmpty) {
loginDialog();
} else {
logOutConfirmDialog();
}
}
if (value == 'scan') {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => ScanPage(),
),
);
}
});
}
}

View File

@@ -204,16 +204,25 @@ class _FileManagerPageState extends State<FileManagerPage> {
setState(() {}); setState(() {});
} else if (v == "folder") { } else if (v == "folder") {
final name = TextEditingController(); final name = TextEditingController();
gFFI.dialogManager String? errorText;
.show((setState, close, context) => CustomAlertDialog( gFFI.dialogManager.show((setState, close, context) {
name.addListener(() {
if (errorText != null) {
setState(() {
errorText = null;
});
}
});
return CustomAlertDialog(
title: Text(translate("Create Folder")), title: Text(translate("Create Folder")),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextFormField( TextFormField(
decoration: InputDecoration( decoration: InputDecoration(
labelText: translate( labelText:
"Please enter the folder name"), translate("Please enter the folder name"),
errorText: errorText,
), ),
controller: name, controller: name,
), ),
@@ -221,19 +230,28 @@ class _FileManagerPageState extends State<FileManagerPage> {
), ),
actions: [ actions: [
dialogButton("Cancel", dialogButton("Cancel",
onPressed: () => close(false), onPressed: () => close(false), isOutline: true),
isOutline: true),
dialogButton("OK", onPressed: () { dialogButton("OK", onPressed: () {
if (name.value.text.isNotEmpty) { if (name.value.text.isNotEmpty) {
currentFileController.createDir( if (!PathUtil.validName(
PathUtil.join( name.value.text,
currentFileController
.options.value.isWindows)) {
setState(() {
errorText =
translate("Invalid folder name");
});
return;
}
currentFileController.createDir(PathUtil.join(
currentDir.path, currentDir.path,
name.value.text, name.value.text,
currentOptions.isWindows)); currentOptions.isWindows));
close(); close();
} }
}) })
])); ]);
});
} else if (v == "hidden") { } else if (v == "hidden") {
currentFileController.toggleShowHidden(); currentFileController.toggleShowHidden();
} }
@@ -497,6 +515,14 @@ class _FileManagerViewState extends State<FileManagerView> {
child: Text(translate("Properties")), child: Text(translate("Properties")),
value: "properties", value: "properties",
enabled: false, enabled: false,
),
if (!entries[index].isDrive &&
versionCmp(gFFI.ffiModel.pi.version,
"1.3.0") >=
0)
PopupMenuItem(
child: Text(translate("Rename")),
value: "rename",
) )
]; ];
}, },
@@ -509,6 +535,9 @@ class _FileManagerViewState extends State<FileManagerView> {
_selectedItems.clear(); _selectedItems.clear();
widget.selectMode.toggle(isLocal); widget.selectMode.toggle(isLocal);
setState(() {}); setState(() {});
} else if (v == "rename") {
controller.renameAction(
entries[index], isLocal);
} }
}), }),
onTap: () { onTap: () {

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/mobile/pages/server_page.dart'; import 'package:flutter_hbb/mobile/pages/server_page.dart';
import 'package:flutter_hbb/mobile/pages/settings_page.dart'; import 'package:flutter_hbb/mobile/pages/settings_page.dart';
import 'package:flutter_hbb/web/settings_page.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../common.dart'; import '../../common.dart';
import '../../common/widgets/chat_page.dart'; import '../../common/widgets/chat_page.dart';
import '../../models/platform_model.dart'; import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'connection_page.dart'; import 'connection_page.dart';
abstract class PageShape extends Widget { abstract class PageShape extends Widget {
@@ -45,7 +47,11 @@ class HomePageState extends State<HomePage> {
void initPages() { void initPages() {
_pages.clear(); _pages.clear();
if (!bind.isIncomingOnly()) _pages.add(ConnectionPage()); if (!bind.isIncomingOnly()) {
_pages.add(ConnectionPage(
appBarActions: [],
));
}
if (isAndroid && !bind.isOutgoingOnly()) { if (isAndroid && !bind.isOutgoingOnly()) {
_chatPageTabIndex = _pages.length; _chatPageTabIndex = _pages.length;
_pages.addAll([ChatPage(type: ChatPageType.mobileMain), ServerPage()]); _pages.addAll([ChatPage(type: ChatPageType.mobileMain), ServerPage()]);
@@ -149,18 +155,80 @@ class HomePageState extends State<HomePage> {
} }
class WebHomePage extends StatelessWidget { class WebHomePage extends StatelessWidget {
final connectionPage = ConnectionPage(); final connectionPage =
ConnectionPage(appBarActions: <Widget>[const WebSettingsPage()]);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
stateGlobal.isInMainPage = true;
handleUnilink(context);
return Scaffold( return Scaffold(
// backgroundColor: MyTheme.grayBg, // backgroundColor: MyTheme.grayBg,
appBar: AppBar( appBar: AppBar(
centerTitle: true, centerTitle: true,
title: Text(bind.mainGetAppNameSync()), title: Text("${bind.mainGetAppNameSync()} (Preview)"),
actions: connectionPage.appBarActions, actions: connectionPage.appBarActions,
), ),
body: connectionPage, body: connectionPage,
); );
} }
handleUnilink(BuildContext context) {
if (webInitialLink.isEmpty) {
return;
}
final link = webInitialLink;
webInitialLink = '';
final splitter = ["/#/", "/#", "#/", "#"];
var fakelink = '';
for (var s in splitter) {
if (link.contains(s)) {
var list = link.split(s);
if (list.length < 2 || list[1].isEmpty) {
return;
}
list.removeAt(0);
fakelink = "rustdesk://${list.join(s)}";
break;
}
}
if (fakelink.isEmpty) {
return;
}
final uri = Uri.tryParse(fakelink);
if (uri == null) {
return;
}
final args = urlLinkToCmdArgs(uri);
if (args == null || args.isEmpty) {
return;
}
bool isFileTransfer = false;
String? id;
String? password;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case '--connect':
case '--play':
isFileTransfer = false;
id = args[i + 1];
i++;
break;
case '--file-transfer':
isFileTransfer = true;
id = args[i + 1];
i++;
break;
case '--password':
password = args[i + 1];
i++;
break;
default:
break;
}
}
if (id != null) {
connect(context, id, isFileTransfer: isFileTransfer, password: password);
}
}
} }

View File

@@ -34,7 +34,7 @@ class RemotePage extends StatefulWidget {
final bool? isSharedPassword; final bool? isSharedPassword;
@override @override
State<RemotePage> createState() => _RemotePageState(); State<RemotePage> createState() => _RemotePageState(id);
} }
class _RemotePageState extends State<RemotePage> { class _RemotePageState extends State<RemotePage> {
@@ -55,6 +55,18 @@ class _RemotePageState extends State<RemotePage> {
InputModel get inputModel => gFFI.inputModel; InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId; SessionID get sessionId => gFFI.sessionId;
final TextEditingController _textController =
TextEditingController(text: initText);
// This timer is used to check the composing status of the soft keyboard.
// It is used for Android, Korean(and other similar) input method.
Timer? _composingTimer;
_RemotePageState(String id) {
initSharedStates(id);
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
gFFI.dialogManager.loadMobileActionsOverlayVisible();
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -77,10 +89,8 @@ class _RemotePageState extends State<RemotePage> {
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId); gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
keyboardSubscription = keyboardSubscription =
keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged); keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged);
initSharedStates(widget.id);
gFFI.chatModel gFFI.chatModel
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID)); .changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
_blockableOverlayState.applyFfi(gFFI); _blockableOverlayState.applyFfi(gFFI);
} }
@@ -88,7 +98,7 @@ class _RemotePageState extends State<RemotePage> {
Future<void> dispose() async { Future<void> dispose() async {
// https://github.com/flutter/flutter/issues/64935 // https://github.com/flutter/flutter/issues/64935
super.dispose(); super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(); gFFI.dialogManager.hideMobileActionsOverlay(store: false);
gFFI.inputModel.listenToMouse(false); gFFI.inputModel.listenToMouse(false);
gFFI.imageModel.disposeImage(); gFFI.imageModel.disposeImage();
gFFI.cursorModel.disposeImages(); gFFI.cursorModel.disposeImages();
@@ -97,6 +107,7 @@ class _RemotePageState extends State<RemotePage> {
_physicalFocusNode.dispose(); _physicalFocusNode.dispose();
await gFFI.close(); await gFFI.close();
_timer?.cancel(); _timer?.cancel();
_composingTimer?.cancel();
gFFI.dialogManager.dismissAll(); gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values); overlays: SystemUiOverlay.values);
@@ -105,11 +116,10 @@ class _RemotePageState extends State<RemotePage> {
} }
await keyboardSubscription.cancel(); await keyboardSubscription.cancel();
removeSharedStates(widget.id); removeSharedStates(widget.id);
if (isAndroid) { // `on_voice_call_closed` should be called when the connection is ended.
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
// Only one client is considered here for now. // Only one client is considered here for now.
// TODO: take into account the case where there are multiple clients gFFI.chatModel.onVoiceCallClosed("End connetion");
gFFI.invokeMethod("on_voice_call_closed");
}
} }
// to-do: It should be better to use transparent color instead of the bgColor. // to-do: It should be better to use transparent color instead of the bgColor.
@@ -133,6 +143,7 @@ class _RemotePageState extends State<RemotePage> {
gFFI.ffiModel.pi.version.isNotEmpty) { gFFI.ffiModel.pi.version.isNotEmpty) {
gFFI.invokeMethod("enable_soft_keyboard", false); gFFI.invokeMethod("enable_soft_keyboard", false);
} }
_composingTimer?.cancel();
} else { } else {
_timer?.cancel(); _timer?.cancel();
_timer = Timer(kMobileDelaySoftKeyboardFocus, () { _timer = Timer(kMobileDelaySoftKeyboardFocus, () {
@@ -145,41 +156,70 @@ class _RemotePageState extends State<RemotePage> {
setState(() {}); setState(() {});
} }
// handle mobile virtual keyboard void _handleIOSSoftKeyboardInput(String newValue) {
void handleSoftKeyboardInput(String newValue) {
var oldValue = _value; var oldValue = _value;
_value = newValue; _value = newValue;
if (isIOS) {
var i = newValue.length - 1; var i = newValue.length - 1;
for (; i >= 0 && newValue[i] != '\1'; --i) {} for (; i >= 0 && newValue[i] != '1'; --i) {}
var j = oldValue.length - 1; var j = oldValue.length - 1;
for (; j >= 0 && oldValue[j] != '\1'; --j) {} for (; j >= 0 && oldValue[j] != '1'; --j) {}
if (i < j) j = i; if (i < j) j = i;
newValue = newValue.substring(j + 1); var subNewValue = newValue.substring(j + 1);
oldValue = oldValue.substring(j + 1); var subOldValue = oldValue.substring(j + 1);
// get common prefix of subNewValue and subOldValue
var common = 0; var common = 0;
for (; for (;
common < oldValue.length && common < subOldValue.length &&
common < newValue.length && common < subNewValue.length &&
newValue[common] == oldValue[common]; subNewValue[common] == subOldValue[common];
++common) {} ++common) {}
for (i = 0; i < oldValue.length - common; ++i) {
inputModel.inputKey('VK_BACK'); // get newStr from subNewValue
} var newStr = "";
if (newValue.length > common) { if (subNewValue.length > common) {
var s = newValue.substring(common); newStr = subNewValue.substring(common);
if (s.length > 1) {
bind.sessionInputString(sessionId: sessionId, value: s);
} else {
inputChar(s);
}
} }
// Set the value to the old value and early return if is still composing. (1 && 2)
// 1. The composing range is valid
// 2. The new string is shorter than the composing range.
if (_textController.value.isComposingRangeValid) {
final composingLength = _textController.value.composing.end -
_textController.value.composing.start;
if (composingLength > newStr.length) {
_value = oldValue;
return; return;
} }
}
// Delete the different part in the old value.
for (i = 0; i < subOldValue.length - common; ++i) {
inputModel.inputKey('VK_BACK');
}
// Input the new string.
if (newStr.length > 1) {
bind.sessionInputString(sessionId: sessionId, value: newStr);
} else {
inputChar(newStr);
}
}
void _handleNonIOSSoftKeyboardInput(String newValue) {
_composingTimer?.cancel();
if (_textController.value.isComposingRangeValid) {
_composingTimer = Timer(Duration(milliseconds: 25), () {
_handleNonIOSSoftKeyboardInput(_textController.value.text);
});
return;
}
var oldValue = _value;
_value = newValue;
if (oldValue.isNotEmpty && if (oldValue.isNotEmpty &&
newValue.isNotEmpty && newValue.isNotEmpty &&
oldValue[0] == '\1' && oldValue[0] == '1' &&
newValue[0] != '\1') { newValue[0] != '1') {
// clipboard // clipboard
oldValue = ''; oldValue = '';
} }
@@ -214,6 +254,19 @@ class _RemotePageState extends State<RemotePage> {
} }
} }
Future<void> handleSoftKeyboardInput(String newValue) async {
if (isIOS) {
// fix: TextFormField onChanged event triggered multiple times when Korean input
// https://github.com/rustdesk/rustdesk/pull/9644
await Future.delayed(const Duration(milliseconds: 10));
if (newValue != _textController.text) return;
_handleIOSSoftKeyboardInput(_textController.text);
} else {
_handleNonIOSSoftKeyboardInput(newValue);
}
}
void inputChar(String char) { void inputChar(String char) {
if (char == '\n') { if (char == '\n') {
char = 'VK_RETURN'; char = 'VK_RETURN';
@@ -227,6 +280,7 @@ class _RemotePageState extends State<RemotePage> {
gFFI.invokeMethod("enable_soft_keyboard", true); gFFI.invokeMethod("enable_soft_keyboard", true);
// destroy first, so that our _value trick can work // destroy first, so that our _value trick can work
_value = initText; _value = initText;
_textController.text = _value;
setState(() => _showEdit = false); setState(() => _showEdit = false);
_timer?.cancel(); _timer?.cancel();
_timer = Timer(kMobileDelaySoftKeyboard, () { _timer = Timer(kMobileDelaySoftKeyboard, () {
@@ -242,12 +296,10 @@ class _RemotePageState extends State<RemotePage> {
}); });
} }
bool get keyboard => gFFI.ffiModel.permissions['keyboard'] != false;
Widget _bottomWidget() => _showGestureHelp Widget _bottomWidget() => _showGestureHelp
? getGestureHelp() ? getGestureHelp()
: (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty : (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
? getBottomAppBar(keyboard) ? getBottomAppBar()
: Offstage()); : Offstage());
@override @override
@@ -314,7 +366,7 @@ class _RemotePageState extends State<RemotePage> {
return Container( return Container(
color: kColorCanvas, color: kColorCanvas,
child: isWebDesktop child: isWebDesktop
? getBodyForDesktopWithListener(keyboard) ? getBodyForDesktopWithListener()
: SafeArea( : SafeArea(
child: child:
OrientationBuilder(builder: (ctx, orientation) { OrientationBuilder(builder: (ctx, orientation) {
@@ -346,9 +398,9 @@ class _RemotePageState extends State<RemotePage> {
} }
Widget getRawPointerAndKeyBody(Widget child) { Widget getRawPointerAndKeyBody(Widget child) {
final keyboard = gFFI.ffiModel.permissions['keyboard'] != false; final ffiModel = Provider.of<FfiModel>(context);
return RawPointerMouseRegion( return RawPointerMouseRegion(
cursor: keyboard ? SystemMouseCursors.none : MouseCursor.defer, cursor: ffiModel.keyboard ? SystemMouseCursors.none : MouseCursor.defer,
inputModel: inputModel, inputModel: inputModel,
// Disable RawKeyFocusScope before the connecting is established. // Disable RawKeyFocusScope before the connecting is established.
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog. // The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
@@ -361,7 +413,8 @@ class _RemotePageState extends State<RemotePage> {
); );
} }
Widget getBottomAppBar(bool keyboard) { Widget getBottomAppBar() {
final ffiModel = Provider.of<FfiModel>(context);
return BottomAppBar( return BottomAppBar(
elevation: 10, elevation: 10,
color: MyTheme.accent, color: MyTheme.accent,
@@ -387,7 +440,7 @@ class _RemotePageState extends State<RemotePage> {
}, },
) )
] + ] +
(isWebDesktop (isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard
? [] ? []
: gFFI.ffiModel.isPeerAndroid : gFFI.ffiModel.isPeerAndroid
? [ ? [
@@ -419,17 +472,21 @@ class _RemotePageState extends State<RemotePage> {
(isWeb (isWeb
? [] ? []
: <Widget>[ : <Widget>[
IconButton( futureBuilder(
future: gFFI.invokeMethod(
"get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
hasData: (isSupportVoiceCall) => IconButton(
color: Colors.white, color: Colors.white,
icon: isAndroid icon: isAndroid && isSupportVoiceCall
? SvgPicture.asset('assets/chat.svg', ? SvgPicture.asset('assets/chat.svg',
colorFilter: ColorFilter.mode( colorFilter: ColorFilter.mode(
Colors.white, BlendMode.srcIn)) Colors.white, BlendMode.srcIn))
: Icon(Icons.message), : Icon(Icons.message),
onPressed: () => isAndroid onPressed: () =>
isAndroid && isSupportVoiceCall
? showChatOptions(widget.id) ? showChatOptions(widget.id)
: onPressedTextChat(widget.id), : onPressedTextChat(widget.id),
) ))
]) + ]) +
[ [
IconButton( IconButton(
@@ -479,11 +536,15 @@ class _RemotePageState extends State<RemotePage> {
: TextFormField( : TextFormField(
textInputAction: TextInputAction.newline, textInputAction: TextInputAction.newline,
autocorrect: false, autocorrect: false,
enableSuggestions: false, // Flutter 3.16.9 Android.
// `enableSuggestions` causes secure keyboard to be shown.
// https://github.com/flutter/flutter/issues/139143
// https://github.com/flutter/flutter/issues/146540
// enableSuggestions: false,
autofocus: true, autofocus: true,
focusNode: _mobileFocusNode, focusNode: _mobileFocusNode,
maxLines: null, maxLines: null,
initialValue: _value, controller: _textController,
// trick way to make backspace work always // trick way to make backspace work always
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
onChanged: handleSoftKeyboardInput, onChanged: handleSoftKeyboardInput,
@@ -491,48 +552,84 @@ class _RemotePageState extends State<RemotePage> {
), ),
]; ];
if (showCursorPaint) { if (showCursorPaint) {
paints.add(CursorPaint()); paints.add(CursorPaint(widget.id));
} }
return paints; return paints;
}())); }()));
} }
Widget getBodyForDesktopWithListener(bool keyboard) { Widget getBodyForDesktopWithListener() {
final ffiModel = Provider.of<FfiModel>(context);
var paints = <Widget>[ImagePaint()]; var paints = <Widget>[ImagePaint()];
if (showCursorPaint) { if (showCursorPaint) {
final cursor = bind.sessionGetToggleOptionSync( final cursor = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'show-remote-cursor'); sessionId: sessionId, arg: 'show-remote-cursor');
if (keyboard || cursor) { if (ffiModel.keyboard || cursor) {
paints.add(CursorPaint()); paints.add(CursorPaint(widget.id));
} }
} }
return Container( return Container(
color: MyTheme.canvasColor, child: Stack(children: paints)); color: MyTheme.canvasColor, child: Stack(children: paints));
} }
List<TTextMenu> _getMobileActionMenus() {
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
!gFFI.ffiModel.keyboard) {
return [];
}
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
if (!enabled) return [];
return [
TTextMenu(
child: Text(translate('Back')),
onPressed: () => gFFI.inputModel.onMobileBack(),
),
TTextMenu(
child: Text(translate('Home')),
onPressed: () => gFFI.inputModel.onMobileHome(),
),
TTextMenu(
child: Text(translate('Apps')),
onPressed: () => gFFI.inputModel.onMobileApps(),
),
TTextMenu(
child: Text(translate('Volume up')),
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
),
TTextMenu(
child: Text(translate('Volume down')),
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
),
TTextMenu(
child: Text(translate('Power')),
onPressed: () => gFFI.inputModel.onMobilePower(),
),
];
}
void showActions(String id) async { void showActions(String id) async {
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
final x = 120.0; final x = 120.0;
final y = size.height; final y = size.height;
final mobileActionMenus = _getMobileActionMenus();
final menus = toolbarControls(context, id, gFFI); final menus = toolbarControls(context, id, gFFI);
getChild(TTextMenu menu) {
if (menu.trailingIcon != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
menu.child,
menu.trailingIcon!,
]);
} else {
return menu.child;
}
}
final more = menus final List<PopupMenuEntry<int>> more = [
...mobileActionMenus
.asMap() .asMap()
.entries .entries
.map((e) => PopupMenuItem<int>(child: getChild(e.value), value: e.key)) .map((e) =>
.toList(); PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList(),
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
...menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(
child: e.value.getChild(),
value: e.key + mobileActionMenus.length))
.toList(),
];
() async { () async {
var index = await showMenu( var index = await showMenu(
context: context, context: context,
@@ -540,8 +637,12 @@ class _RemotePageState extends State<RemotePage> {
items: more, items: more,
elevation: 8, elevation: 8,
); );
if (index != null && index < menus.length) { if (index != null) {
menus[index].onPressed.call(); if (index < mobileActionMenus.length) {
mobileActionMenus[index].onPressed.call();
} else if (index < mobileActionMenus.length + more.length) {
menus[index - mobileActionMenus.length].onPressed.call();
}
} }
}(); }();
} }
@@ -561,11 +662,13 @@ class _RemotePageState extends State<RemotePage> {
child: Text(translate(label), style: labelStyle), child: Text(translate(label), style: labelStyle),
trailingIcon: Transform.scale( trailingIcon: Transform.scale(
scale: (isDesktop || isWebDesktop) ? 0.8 : 1, scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
child: IgnorePointer(
child: IconButton( child: IconButton(
onPressed: onPressed, onPressed: null,
icon: icon, icon: icon,
), ),
), ),
),
onPressed: onPressed, onPressed: onPressed,
); );
@@ -594,23 +697,11 @@ class _RemotePageState extends State<RemotePage> {
), ),
onPressVoiceCall), onPressVoiceCall),
]; ];
getChild(TTextMenu menu) {
if (menu.trailingIcon != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
menu.child,
menu.trailingIcon!,
]);
} else {
return menu.child;
}
}
final menuItems = menus final menuItems = menus
.asMap() .asMap()
.entries .entries
.map((e) => PopupMenuItem<int>(child: getChild(e.value), value: e.key)) .map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList(); .toList();
Future.delayed(Duration.zero, () async { Future.delayed(Duration.zero, () async {
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
@@ -679,6 +770,7 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
var _fn = false; var _fn = false;
var _pin = false; var _pin = false;
final _keyboardVisibilityController = KeyboardVisibilityController(); final _keyboardVisibilityController = KeyboardVisibilityController();
final _key = GlobalKey();
InputModel get inputModel => gFFI.inputModel; InputModel get inputModel => gFFI.inputModel;
@@ -703,6 +795,19 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
onPressed: onPressed); onPressed: onPressed);
} }
_updateRect() {
RenderObject? renderObject = _key.currentContext?.findRenderObject();
if (renderObject == null) {
return;
}
if (renderObject is RenderBox) {
final size = renderObject.size;
Offset pos = renderObject.localToGlobal(Offset.zero);
gFFI.cursorModel.keyHelpToolsVisibilityChanged(
Rect.fromLTWH(pos.dx, pos.dy, size.width, size.height));
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasModifierOn = inputModel.ctrl || final hasModifierOn = inputModel.ctrl ||
@@ -711,6 +816,7 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
inputModel.command; inputModel.command;
if (!_pin && !hasModifierOn && !widget.requestShow) { if (!_pin && !hasModifierOn && !widget.requestShow) {
gFFI.cursorModel.keyHelpToolsVisibilityChanged(null);
return Offstage(); return Offstage();
} }
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
@@ -821,7 +927,12 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
}), }),
]; ];
final space = size.width > 320 ? 4.0 : 2.0; final space = size.width > 320 ? 4.0 : 2.0;
// 500 ms is long enough for this widget to be built!
Future.delayed(Duration(milliseconds: 500), () {
_updateRect();
});
return Container( return Container(
key: _key,
color: Color(0xAA000000), color: Color(0xAA000000),
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: _keyboardVisibilityController.isVisible ? 24 : 4, bottom: 8), top: _keyboardVisibilityController.isVisible ? 24 : 4, bottom: 8),
@@ -852,26 +963,52 @@ class ImagePaint extends StatelessWidget {
} }
class CursorPaint extends StatelessWidget { class CursorPaint extends StatelessWidget {
late final String id;
CursorPaint(this.id);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final m = Provider.of<CursorModel>(context); final m = Provider.of<CursorModel>(context);
final c = Provider.of<CanvasModel>(context); final c = Provider.of<CanvasModel>(context);
final ffiModel = Provider.of<FfiModel>(context);
final adjust = gFFI.cursorModel.adjustForKeyboard(); final adjust = gFFI.cursorModel.adjustForKeyboard();
var s = c.scale; final s = c.scale;
double hotx = m.hotx; double hotx = m.hotx;
double hoty = m.hoty; double hoty = m.hoty;
if (m.image == null) { var image = m.image;
if (image == null) {
if (preDefaultCursor.image != null) { if (preDefaultCursor.image != null) {
image = preDefaultCursor.image;
hotx = preDefaultCursor.image!.width / 2; hotx = preDefaultCursor.image!.width / 2;
hoty = preDefaultCursor.image!.height / 2; hoty = preDefaultCursor.image!.height / 2;
} }
} }
if (preForbiddenCursor.image != null &&
!ffiModel.viewOnly &&
!ffiModel.keyboard &&
!ShowRemoteCursorState.find(id).value) {
image = preForbiddenCursor.image;
hotx = preForbiddenCursor.image!.width / 2;
hoty = preForbiddenCursor.image!.height / 2;
}
if (image == null) {
return Offstage();
}
final minSize = 12.0;
double mins =
minSize / (image.width > image.height ? image.width : image.height);
double factor = 1.0;
if (s < mins) {
factor = s / mins;
}
final s2 = s < mins ? mins : s;
return CustomPaint( return CustomPaint(
painter: ImagePainter( painter: ImagePainter(
image: m.image ?? preDefaultCursor.image, image: image,
x: m.x * s - hotx + c.x, x: (m.x - hotx) * factor + c.x / s2,
y: m.y * s - hoty + c.y - adjust, y: (m.y - hoty) * factor + (c.y - adjust) / s2,
scale: 1), scale: s2),
); );
} }
} }
@@ -901,7 +1038,7 @@ void showOptions(
border: Border.all(color: Theme.of(context).hintColor), border: Border.all(color: Theme.of(context).hintColor),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
color: i == cur color: i == cur
? Theme.of(context).toggleableActiveColor.withOpacity(0.6) ? Theme.of(context).primaryColor.withOpacity(0.6)
: null), : null),
child: Center( child: Center(
child: Text((i + 1).toString(), child: Text((i + 1).toString(),
@@ -949,22 +1086,40 @@ void showOptions(
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs; var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
final radios = [ final radios = [
for (var e in viewStyleRadios) for (var e in viewStyleRadios)
Obx(() => getRadio<String>(e.child, e.value, viewStyle.value, (v) { Obx(() => getRadio<String>(
e.child,
e.value,
viewStyle.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v); e.onChanged?.call(v);
if (v != null) viewStyle.value = v; if (v != null) viewStyle.value = v;
})), }
: null)),
const Divider(color: MyTheme.border), const Divider(color: MyTheme.border),
for (var e in imageQualityRadios) for (var e in imageQualityRadios)
Obx(() => getRadio<String>(e.child, e.value, imageQuality.value, (v) { Obx(() => getRadio<String>(
e.child,
e.value,
imageQuality.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v); e.onChanged?.call(v);
if (v != null) imageQuality.value = v; if (v != null) imageQuality.value = v;
})), }
: null)),
const Divider(color: MyTheme.border), const Divider(color: MyTheme.border),
for (var e in codecRadios) for (var e in codecRadios)
Obx(() => getRadio<String>(e.child, e.value, codec.value, (v) { Obx(() => getRadio<String>(
e.child,
e.value,
codec.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v); e.onChanged?.call(v);
if (v != null) codec.value = v; if (v != null) codec.value = v;
})), }
: null)),
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border), if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
]; ];
final rxCursorToggleValues = cursorToggles.map((e) => e.value.obs).toList(); final rxCursorToggleValues = cursorToggles.map((e) => e.value.obs).toList();
@@ -975,10 +1130,12 @@ void showOptions(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
value: rxCursorToggleValues[e.key].value, value: rxCursorToggleValues[e.key].value,
onChanged: (v) { onChanged: e.value.onChanged != null
? (v) {
e.value.onChanged?.call(v); e.value.onChanged?.call(v);
if (v != null) rxCursorToggleValues[e.key].value = v; if (v != null) rxCursorToggleValues[e.key].value = v;
}, }
: null,
title: e.value.child))) title: e.value.child)))
.toList(); .toList();
@@ -990,10 +1147,12 @@ void showOptions(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
value: rxToggleValues[e.key].value, value: rxToggleValues[e.key].value,
onChanged: (v) { onChanged: e.value.onChanged != null
? (v) {
e.value.onChanged?.call(v); e.value.onChanged?.call(v);
if (v != null) rxToggleValues[e.key].value = v; if (v != null) rxToggleValues[e.key].value = v;
}, }
: null,
title: e.value.child))) title: e.value.child)))
.toList(); .toList();
final toggles = [ final toggles = [
@@ -1013,14 +1172,110 @@ void showOptions(
); );
} }
var popupDialogMenus = List<Widget>.empty(growable: true);
final resolution = getResolutionMenu(gFFI, id);
if (resolution != null) {
popupDialogMenus.add(ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
title: resolution.child,
onTap: () {
close();
resolution.onPressed();
},
));
}
final virtualDisplayMenu = getVirtualDisplayMenu(gFFI, id);
if (virtualDisplayMenu != null) {
popupDialogMenus.add(ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
title: virtualDisplayMenu.child,
onTap: () {
close();
virtualDisplayMenu.onPressed();
},
));
}
if (popupDialogMenus.isNotEmpty) {
popupDialogMenus.add(const Divider(color: MyTheme.border));
}
return CustomAlertDialog( return CustomAlertDialog(
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: displays + radios + toggles + [privacyModeWidget]), children: displays +
radios +
popupDialogMenus +
toggles +
[privacyModeWidget]),
); );
}, clickMaskDismiss: true, backDismiss: true); }, clickMaskDismiss: true, backDismiss: true);
} }
TTextMenu? getVirtualDisplayMenu(FFI ffi, String id) {
if (!showVirtualDisplayMenu(ffi)) {
return null;
}
return TTextMenu(
child: Text(translate("Virtual display")),
onPressed: () {
ffi.dialogManager.show((setState, close, context) {
final children = getVirtualDisplayMenuChildren(ffi, id, close);
return CustomAlertDialog(
title: Text(translate('Virtual display')),
content: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
);
}, clickMaskDismiss: true, backDismiss: true);
},
);
}
TTextMenu? getResolutionMenu(FFI ffi, String id) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final resolutions = pi.resolutions;
final display = pi.tryGetDisplayIfNotAllDisplay(display: pi.currentDisplay);
final visible =
ffiModel.keyboard && (resolutions.length > 1) && display != null;
if (!visible) return null;
return TTextMenu(
child: Text(translate("Resolution")),
onPressed: () {
ffi.dialogManager.show((setState, close, context) {
final children = resolutions
.map((e) => getRadio<String>(
Text('${e.width}x${e.height}'),
'${e.width}x${e.height}',
'${display.width}x${display.height}',
(value) {
close();
bind.sessionChangeResolution(
sessionId: ffi.sessionId,
display: pi.currentDisplay,
width: e.width,
height: e.height,
);
},
))
.toList();
return CustomAlertDialog(
title: Text(translate('Resolution')),
content: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
);
}, clickMaskDismiss: true, backDismiss: true);
},
);
}
void sendPrompt(bool isMac, String key) { void sendPrompt(bool isMac, String key) {
final old = isMac ? gFFI.inputModel.command : gFFI.inputModel.ctrl; final old = isMac ? gFFI.inputModel.command : gFFI.inputModel.ctrl;
if (isMac) { if (isMac) {

View File

@@ -19,17 +19,17 @@ class ScanPage extends StatefulWidget {
class _ScanPageState extends State<ScanPage> { class _ScanPageState extends State<ScanPage> {
QRViewController? controller; QRViewController? controller;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
StreamSubscription? scanSubscription;
// In order to get hot reload to work we need to pause the camera if the platform
// is android, or resume the camera if the platform is iOS.
@override @override
void reassemble() { void reassemble() {
super.reassemble(); super.reassemble();
if (isAndroid) { if (isAndroid && controller != null) {
controller!.pauseCamera(); controller!.pauseCamera();
} } else if (controller != null) {
controller!.resumeCamera(); controller!.resumeCamera();
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -37,68 +37,20 @@ class _ScanPageState extends State<ScanPage> {
appBar: AppBar( appBar: AppBar(
title: const Text('Scan QR'), title: const Text('Scan QR'),
actions: [ actions: [
IconButton( _buildImagePickerButton(),
color: Colors.white, _buildFlashToggleButton(),
icon: Icon(Icons.image_search), _buildCameraSwitchButton(),
iconSize: 32.0,
onPressed: () async {
final ImagePicker picker = ImagePicker();
final XFile? file =
await picker.pickImage(source: ImageSource.gallery);
if (file != null) {
var image = img.decodeNamedImage(
file.path, File(file.path).readAsBytesSync())!;
LuminanceSource source = RGBLuminanceSource(
image.width,
image.height,
image
.getBytes(order: img.ChannelOrder.abgr)
.buffer
.asInt32List());
var bitmap = BinaryBitmap(HybridBinarizer(source));
var reader = QRCodeReader();
try {
var result = reader.decode(bitmap);
if (result.text.startsWith(bind.mainUriPrefixSync())) {
handleUriLink(uriString: result.text);
} else {
showServerSettingFromQr(result.text);
}
} catch (e) {
showToast('No QR code found');
}
}
}),
IconButton(
color: Colors.yellow,
icon: Icon(Icons.flash_on),
iconSize: 32.0,
onPressed: () async {
await controller?.toggleFlash();
}),
IconButton(
color: Colors.white,
icon: Icon(Icons.switch_camera),
iconSize: 32.0,
onPressed: () async {
await controller?.flipCamera();
},
),
], ],
), ),
body: _buildQrView(context)); body: _buildQrView(context),
);
} }
Widget _buildQrView(BuildContext context) { Widget _buildQrView(BuildContext context) {
// For this example we check how width or tall the device is and change the scanArea and overlay accordingly. var scanArea = MediaQuery.of(context).size.width < 400 ||
var scanArea = (MediaQuery.of(context).size.width < 400 || MediaQuery.of(context).size.height < 400
MediaQuery.of(context).size.height < 400)
? 150.0 ? 150.0
: 300.0; : 300.0;
// To ensure the Scanner view is properly sizes after rotation
// we need to listen for Flutter SizeChanged notification and update controller
return QRView( return QRView(
key: qrKey, key: qrKey,
onQRViewCreated: _onQRViewCreated, onQRViewCreated: _onQRViewCreated,
@@ -107,7 +59,8 @@ class _ScanPageState extends State<ScanPage> {
borderRadius: 10, borderRadius: 10,
borderLength: 30, borderLength: 30,
borderWidth: 10, borderWidth: 10,
cutOutSize: scanArea), cutOutSize: scanArea,
),
onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p),
); );
} }
@@ -116,7 +69,7 @@ class _ScanPageState extends State<ScanPage> {
setState(() { setState(() {
this.controller = controller; this.controller = controller;
}); });
controller.scannedDataStream.listen((scanData) { scanSubscription = controller.scannedDataStream.listen((scanData) {
if (scanData.code != null) { if (scanData.code != null) {
showServerSettingFromQr(scanData.code!); showServerSettingFromQr(scanData.code!);
} }
@@ -129,8 +82,66 @@ class _ScanPageState extends State<ScanPage> {
} }
} }
Future<void> _pickImage() async {
final ImagePicker picker = ImagePicker();
final XFile? file = await picker.pickImage(source: ImageSource.gallery);
if (file != null) {
try {
var image = img.decodeImage(await File(file.path).readAsBytes())!;
LuminanceSource source = RGBLuminanceSource(
image.width,
image.height,
image.getBytes(order: img.ChannelOrder.abgr).buffer.asInt32List(),
);
var bitmap = BinaryBitmap(HybridBinarizer(source));
var reader = QRCodeReader();
var result = reader.decode(bitmap);
if (result.text.startsWith(bind.mainUriPrefixSync())) {
handleUriLink(uriString: result.text);
} else {
showServerSettingFromQr(result.text);
}
} catch (e) {
showToast('No QR code found');
}
}
}
Widget _buildImagePickerButton() {
return IconButton(
color: Colors.white,
icon: Icon(Icons.image_search),
iconSize: 32.0,
onPressed: _pickImage,
);
}
Widget _buildFlashToggleButton() {
return IconButton(
color: Colors.yellow,
icon: Icon(Icons.flash_on),
iconSize: 32.0,
onPressed: () async {
await controller?.toggleFlash();
},
);
}
Widget _buildCameraSwitchButton() {
return IconButton(
color: Colors.white,
icon: Icon(Icons.switch_camera),
iconSize: 32.0,
onPressed: () async {
await controller?.flipCamera();
},
);
}
@override @override
void dispose() { void dispose() {
scanSubscription?.cancel();
controller?.dispose(); controller?.dispose();
super.dispose(); super.dispose();
} }

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
import 'package:flutter_hbb/mobile/widgets/dialog.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart';
import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/chat_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -22,7 +23,22 @@ class ServerPage extends StatefulWidget implements PageShape {
final icon = const Icon(Icons.mobile_screen_share); final icon = const Icon(Icons.mobile_screen_share);
@override @override
final appBarActions = [ final appBarActions = (!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y')
? [_DropDownAction()]
: [];
ServerPage({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => _ServerPageState();
}
class _DropDownAction extends StatelessWidget {
_DropDownAction();
// should only have one action
final actions = [
PopupMenuButton<String>( PopupMenuButton<String>(
tooltip: "", tooltip: "",
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
@@ -101,18 +117,27 @@ class ServerPage extends StatefulWidget implements PageShape {
), ),
]; ];
}, },
onSelected: (value) { onSelected: (value) async {
if (value == "changeID") { if (value == "changeID") {
changeIdDialog(); changeIdDialog();
} else if (value == "setPermanentPassword") { } else if (value == "setPermanentPassword") {
setPermanentPasswordDialog(gFFI.dialogManager); setPasswordDialog();
} else if (value == "setTemporaryPasswordLength") { } else if (value == "setTemporaryPasswordLength") {
setTemporaryPasswordLengthDialog(gFFI.dialogManager); setTemporaryPasswordLengthDialog(gFFI.dialogManager);
} else if (value == kUsePermanentPassword || } else if (value == kUsePermanentPassword ||
value == kUseTemporaryPassword || value == kUseTemporaryPassword ||
value == kUseBothPasswords) { value == kUseBothPasswords) {
callback() {
bind.mainSetOption(key: kOptionVerificationMethod, value: value); bind.mainSetOption(key: kOptionVerificationMethod, value: value);
gFFI.serverModel.updatePasswordModel(); gFFI.serverModel.updatePasswordModel();
}
if (value == kUsePermanentPassword &&
(await bind.mainGetPermanentPassword()).isEmpty) {
setPasswordDialog(notEmptyCallback: callback);
} else {
callback();
}
} else if (value.startsWith("AcceptSessionsVia")) { } else if (value.startsWith("AcceptSessionsVia")) {
value = value.substring("AcceptSessionsVia".length); value = value.substring("AcceptSessionsVia".length);
if (value == "Password") { if (value == "Password") {
@@ -126,10 +151,10 @@ class ServerPage extends StatefulWidget implements PageShape {
}) })
]; ];
ServerPage({Key? key}) : super(key: key);
@override @override
State<StatefulWidget> createState() => _ServerPageState(); Widget build(BuildContext context) {
return actions[0];
}
} }
class _ServerPageState extends State<ServerPage> { class _ServerPageState extends State<ServerPage> {
@@ -162,7 +187,7 @@ class _ServerPageState extends State<ServerPage> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
buildPresetPasswordWarning(), buildPresetPasswordWarningMobile(),
gFFI.serverModel.isStart gFFI.serverModel.isStart
? ServerInfo() ? ServerInfo()
: ServiceNotRunningNotification(), : ServiceNotRunningNotification(),
@@ -854,6 +879,15 @@ void androidChannelInit() {
msgBox(gFFI.sessionId, type, title, text, link, gFFI.dialogManager); msgBox(gFFI.sessionId, type, title, text, link, gFFI.dialogManager);
break; break;
} }
case "stop_service":
{
print(
"stop_service by kotlin, isStart:${gFFI.serverModel.isStart}");
if (gFFI.serverModel.isStart) {
gFFI.serverModel.stopService();
}
break;
}
} }
} catch (e) { } catch (e) {
debugPrintStack(label: "MethodCallHandler err:$e"); debugPrintStack(label: "MethodCallHandler err:$e");

View File

@@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:settings_ui/settings_ui.dart'; import 'package:settings_ui/settings_ui.dart';
@@ -35,10 +37,41 @@ class SettingsPage extends StatefulWidget implements PageShape {
const url = 'https://rustdesk.com/'; const url = 'https://rustdesk.com/';
enum KeepScreenOn {
never,
duringControlled,
serviceOn,
}
String _keepScreenOnToOption(KeepScreenOn value) {
switch (value) {
case KeepScreenOn.never:
return 'never';
case KeepScreenOn.duringControlled:
return 'during-controlled';
case KeepScreenOn.serviceOn:
return 'service-on';
}
}
KeepScreenOn optionToKeepScreenOn(String value) {
switch (value) {
case 'never':
return KeepScreenOn.never;
case 'service-on':
return KeepScreenOn.serviceOn;
default:
return KeepScreenOn.duringControlled;
}
}
class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver { class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
final _hasIgnoreBattery = androidVersion >= 26; final _hasIgnoreBattery =
false; //androidVersion >= 26; // remove because not work on every device
var _ignoreBatteryOpt = false; var _ignoreBatteryOpt = false;
var _enableStartOnBoot = false; var _enableStartOnBoot = false;
var _floatingWindowDisabled = false;
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
var _enableAbr = false; var _enableAbr = false;
var _denyLANDiscovery = false; var _denyLANDiscovery = false;
var _onlyWhiteList = false; var _onlyWhiteList = false;
@@ -52,13 +85,45 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _fingerprint = ""; var _fingerprint = "";
var _buildDate = ""; var _buildDate = "";
var _autoDisconnectTimeout = ""; var _autoDisconnectTimeout = "";
var _hideServer = false;
var _hideProxy = false;
var _hideNetwork = false;
var _enableTrustedDevices = false;
_SettingsState() {
_enableAbr = option2bool(
kOptionEnableAbr, bind.mainGetOptionSync(key: kOptionEnableAbr));
_denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery,
bind.mainGetOptionSync(key: kOptionEnableLanDiscovery));
_onlyWhiteList = whitelistNotEmpty();
_enableDirectIPAccess = option2bool(
kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer));
_enableRecordSession = option2bool(kOptionEnableRecordSession,
bind.mainGetOptionSync(key: kOptionEnableRecordSession));
_enableHardwareCodec = option2bool(kOptionEnableHwcodec,
bind.mainGetOptionSync(key: kOptionEnableHwcodec));
_autoRecordIncomingSession = option2bool(kOptionAllowAutoRecordIncoming,
bind.mainGetOptionSync(key: kOptionAllowAutoRecordIncoming));
_localIP = bind.mainGetOptionSync(key: 'local-ip-addr');
_directAccessPort = bind.mainGetOptionSync(key: kOptionDirectAccessPort);
_allowAutoDisconnect = option2bool(kOptionAllowAutoDisconnect,
bind.mainGetOptionSync(key: kOptionAllowAutoDisconnect));
_autoDisconnectTimeout =
bind.mainGetOptionSync(key: kOptionAutoDisconnectTimeout);
_hideServer =
bind.mainGetBuildinOption(key: kOptionHideServerSetting) == 'Y';
_hideProxy = bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y';
_hideNetwork =
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) == 'Y';
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
() async { WidgetsBinding.instance.addPostFrameCallback((_) async {
var update = false; var update = false;
if (_hasIgnoreBattery) { if (_hasIgnoreBattery) {
@@ -86,67 +151,21 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_enableStartOnBoot = enableStartOnBoot; _enableStartOnBoot = enableStartOnBoot;
} }
final enableAbrRes = option2bool( var floatingWindowDisabled =
kOptionEnableAbr, await bind.mainGetOption(key: kOptionEnableAbr)); bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) == "Y" ||
if (enableAbrRes != _enableAbr) { !await AndroidPermissionManager.check(kSystemAlertWindow);
if (floatingWindowDisabled != _floatingWindowDisabled) {
update = true; update = true;
_enableAbr = enableAbrRes; _floatingWindowDisabled = floatingWindowDisabled;
} }
final denyLanDiscovery = !option2bool('enable-lan-discovery', final keepScreenOn = _floatingWindowDisabled
await bind.mainGetOption(key: 'enable-lan-discovery')); ? KeepScreenOn.never
if (denyLanDiscovery != _denyLANDiscovery) { : optionToKeepScreenOn(
bind.mainGetLocalOption(key: kOptionKeepScreenOn));
if (keepScreenOn != _keepScreenOn) {
update = true; update = true;
_denyLANDiscovery = denyLanDiscovery; _keepScreenOn = keepScreenOn;
}
final onlyWhiteList = (await bind.mainGetOption(key: kOptionWhitelist)) !=
defaultOptionWhitelist;
if (onlyWhiteList != _onlyWhiteList) {
update = true;
_onlyWhiteList = onlyWhiteList;
}
final enableDirectIPAccess = option2bool(kOptionDirectServer,
await bind.mainGetOption(key: kOptionDirectServer));
if (enableDirectIPAccess != _enableDirectIPAccess) {
update = true;
_enableDirectIPAccess = enableDirectIPAccess;
}
final enableRecordSession = option2bool(kOptionEnableRecordSession,
await bind.mainGetOption(key: kOptionEnableRecordSession));
if (enableRecordSession != _enableRecordSession) {
update = true;
_enableRecordSession = enableRecordSession;
}
final enableHardwareCodec = option2bool(kOptionEnableHwcodec,
await bind.mainGetOption(key: kOptionEnableHwcodec));
if (_enableHardwareCodec != enableHardwareCodec) {
update = true;
_enableHardwareCodec = enableHardwareCodec;
}
final autoRecordIncomingSession = option2bool(
kOptionAllowAutoRecordIncoming,
await bind.mainGetOption(key: kOptionAllowAutoRecordIncoming));
if (autoRecordIncomingSession != _autoRecordIncomingSession) {
update = true;
_autoRecordIncomingSession = autoRecordIncomingSession;
}
final localIP = await bind.mainGetOption(key: 'local-ip-addr');
if (localIP != _localIP) {
update = true;
_localIP = localIP;
}
final directAccessPort =
await bind.mainGetOption(key: kOptionDirectAccessPort);
if (directAccessPort != _directAccessPort) {
update = true;
_directAccessPort = directAccessPort;
} }
final fingerprint = await bind.mainGetFingerprint(); final fingerprint = await bind.mainGetFingerprint();
@@ -160,25 +179,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
update = true; update = true;
_buildDate = buildDate; _buildDate = buildDate;
} }
final allowAutoDisconnect = option2bool(kOptionAllowAutoDisconnect,
await bind.mainGetOption(key: kOptionAllowAutoDisconnect));
if (allowAutoDisconnect != _allowAutoDisconnect) {
update = true;
_allowAutoDisconnect = allowAutoDisconnect;
}
final autoDisconnectTimeout =
await bind.mainGetOption(key: kOptionAutoDisconnectTimeout);
if (autoDisconnectTimeout != _autoDisconnectTimeout) {
update = true;
_autoDisconnectTimeout = autoDisconnectTimeout;
}
if (update) { if (update) {
setState(() {}); setState(() {});
} }
}(); });
} }
@override @override
@@ -242,18 +246,76 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
], ],
)); ));
final List<AbstractSettingsTile> enhancementsTiles = []; final List<AbstractSettingsTile> enhancementsTiles = [];
final List<AbstractSettingsTile> shareScreenTiles = [ final enable2fa = bind.mainHasValid2FaSync();
final List<AbstractSettingsTile> tfaTiles = [
SettingsTile.switchTile( SettingsTile.switchTile(
title: Text(translate('enable-2fa-title')), title: Text(translate('enable-2fa-title')),
initialValue: bind.mainHasValid2FaSync(), initialValue: enable2fa,
onToggle: (_) async { onToggle: (v) async {
update() async { update() async {
setState(() {}); setState(() {});
} }
if (v == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-2fa-confirm-tip'), () {
change2fa(callback: update); change2fa(callback: update);
});
} else {
change2fa(callback: update);
}
}, },
), ),
if (enable2fa)
SettingsTile.switchTile(
title: Text(translate('Telegram bot')),
initialValue: bind.mainHasValidBotSync(),
onToggle: (v) async {
update() async {
setState(() {});
}
if (v == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-bot-confirm-tip'), () {
changeBot(callback: update);
});
} else {
changeBot(callback: update);
}
},
),
if (enable2fa)
SettingsTile.switchTile(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate('Enable trusted devices')),
Text('* ${translate('enable-trusted-devices-tip')}',
style: Theme.of(context).textTheme.bodySmall),
],
),
initialValue: _enableTrustedDevices,
onToggle: isOptionFixed(kOptionEnableTrustedDevices)
? null
: (v) async {
mainSetBoolOption(kOptionEnableTrustedDevices, v);
setState(() {
_enableTrustedDevices = v;
});
},
),
if (enable2fa && _enableTrustedDevices)
SettingsTile(
title: Text(translate('Manage trusted devices')),
trailing: Icon(Icons.arrow_forward_ios),
onPressed: (context) {
Navigator.push(context, MaterialPageRoute(builder: (context) {
return _ManageTrustedDevices();
}));
})
];
final List<AbstractSettingsTile> shareScreenTiles = [
SettingsTile.switchTile( SettingsTile.switchTile(
title: Text(translate('Deny LAN discovery')), title: Text(translate('Deny LAN discovery')),
initialValue: _denyLANDiscovery, initialValue: _denyLANDiscovery,
@@ -282,9 +344,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
initialValue: _onlyWhiteList, initialValue: _onlyWhiteList,
onToggle: (_) async { onToggle: (_) async {
update() async { update() async {
final onlyWhiteList = final onlyWhiteList = whitelistNotEmpty();
(await bind.mainGetOption(key: kOptionWhitelist)) !=
defaultOptionWhitelist;
if (onlyWhiteList != _onlyWhiteList) { if (onlyWhiteList != _onlyWhiteList) {
setState(() { setState(() {
_onlyWhiteList = onlyWhiteList; _onlyWhiteList = onlyWhiteList;
@@ -301,10 +361,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onToggle: isOptionFixed(kOptionEnableAbr) onToggle: isOptionFixed(kOptionEnableAbr)
? null ? null
: (v) async { : (v) async {
await bind.mainSetOption( await mainSetBoolOption(kOptionEnableAbr, v);
key: kOptionEnableAbr, value: v ? defaultOptionYes : "N"); final newValue = await mainGetBoolOption(kOptionEnableAbr);
final newValue =
await bind.mainGetOption(key: kOptionEnableAbr) != "N";
setState(() { setState(() {
_enableAbr = newValue; _enableAbr = newValue;
}); });
@@ -316,12 +374,9 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onToggle: isOptionFixed(kOptionEnableRecordSession) onToggle: isOptionFixed(kOptionEnableRecordSession)
? null ? null
: (v) async { : (v) async {
await bind.mainSetOption( await mainSetBoolOption(kOptionEnableRecordSession, v);
key: kOptionEnableRecordSession,
value: v ? defaultOptionYes : "N");
final newValue = final newValue =
await bind.mainGetOption(key: kOptionEnableRecordSession) != await mainGetBoolOption(kOptionEnableRecordSession);
"N";
setState(() { setState(() {
_enableRecordSession = newValue; _enableRecordSession = newValue;
}); });
@@ -493,7 +548,59 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
gFFI.invokeMethod(AndroidChannel.kSetStartOnBootOpt, toValue); gFFI.invokeMethod(AndroidChannel.kSetStartOnBootOpt, toValue);
})); }));
onFloatingWindowChanged(bool toValue) async {
if (toValue) {
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {
if (!await AndroidPermissionManager.request(kSystemAlertWindow)) {
return;
}
}
}
final disable = !toValue;
bind.mainSetLocalOption(
key: kOptionDisableFloatingWindow,
value: disable ? 'Y' : defaultOptionNo);
setState(() => _floatingWindowDisabled = disable);
gFFI.serverModel.androidUpdatekeepScreenOn();
}
enhancementsTiles.add(SettingsTile.switchTile(
initialValue: !_floatingWindowDisabled,
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(translate('Floating window')),
Text('* ${translate('floating_window_tip')}',
style: Theme.of(context).textTheme.bodySmall),
]),
onToggle: bind.mainIsOptionFixed(key: kOptionDisableFloatingWindow)
? null
: onFloatingWindowChanged));
enhancementsTiles.add(_getPopupDialogRadioEntry(
title: 'Keep screen on',
list: [
_RadioEntry('Never', _keepScreenOnToOption(KeepScreenOn.never)),
_RadioEntry('During controlled',
_keepScreenOnToOption(KeepScreenOn.duringControlled)),
_RadioEntry('During service is on',
_keepScreenOnToOption(KeepScreenOn.serviceOn)),
],
getter: () => _keepScreenOnToOption(_floatingWindowDisabled
? KeepScreenOn.never
: optionToKeepScreenOn(
bind.mainGetLocalOption(key: kOptionKeepScreenOn))),
asyncSetter: isOptionFixed(kOptionKeepScreenOn) || _floatingWindowDisabled
? null
: (value) async {
await bind.mainSetLocalOption(
key: kOptionKeepScreenOn, value: value);
setState(() => _keepScreenOn = optionToKeepScreenOn(value));
gFFI.serverModel.androidUpdatekeepScreenOn();
},
));
final disabledSettings = bind.isDisableSettings(); final disabledSettings = bind.isDisableSettings();
final hideSecuritySettings =
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) == 'Y';
final settings = SettingsList( final settings = SettingsList(
sections: [ sections: [
customClientSection, customClientSection,
@@ -517,13 +624,20 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
], ],
), ),
SettingsSection(title: Text(translate("Settings")), tiles: [ SettingsSection(title: Text(translate("Settings")), tiles: [
if (!disabledSettings) if (!disabledSettings && !_hideNetwork && !_hideServer)
SettingsTile( SettingsTile(
title: Text(translate('ID/Relay Server')), title: Text(translate('ID/Relay Server')),
leading: Icon(Icons.cloud), leading: Icon(Icons.cloud),
onPressed: (context) { onPressed: (context) {
showServerSettings(gFFI.dialogManager); showServerSettings(gFFI.dialogManager);
}), }),
if (!isIOS && !_hideNetwork && !_hideProxy)
SettingsTile(
title: Text(translate('Socks5/Http(s) Proxy')),
leading: Icon(Icons.network_ping),
onPressed: (context) {
changeSocks5Proxy();
}),
SettingsTile( SettingsTile(
title: Text(translate('Language')), title: Text(translate('Language')),
leading: Icon(Icons.translate), leading: Icon(Icons.translate),
@@ -533,8 +647,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
SettingsTile( SettingsTile(
title: Text(translate( title: Text(translate(
Theme.of(context).brightness == Brightness.light Theme.of(context).brightness == Brightness.light
? 'Dark Theme' ? 'Light Theme'
: 'Light Theme')), : 'Dark Theme')),
leading: Icon(Theme.of(context).brightness == Brightness.light leading: Icon(Theme.of(context).brightness == Brightness.light
? Icons.dark_mode ? Icons.dark_mode
: Icons.light_mode), : Icons.light_mode),
@@ -551,12 +665,9 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onToggle: isOptionFixed(kOptionEnableHwcodec) onToggle: isOptionFixed(kOptionEnableHwcodec)
? null ? null
: (v) async { : (v) async {
await bind.mainSetOption( await mainSetBoolOption(kOptionEnableHwcodec, v);
key: kOptionEnableHwcodec,
value: v ? defaultOptionYes : "N");
final newValue = final newValue =
await bind.mainGetOption(key: kOptionEnableHwcodec) != await mainGetBoolOption(kOptionEnableHwcodec);
"N";
setState(() { setState(() {
_enableHardwareCodec = newValue; _enableHardwareCodec = newValue;
}); });
@@ -571,11 +682,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
title: title:
Text(translate('Automatically record incoming sessions')), Text(translate('Automatically record incoming sessions')),
leading: Icon(Icons.videocam), leading: Icon(Icons.videocam),
description: FutureBuilder( description: Text(
builder: (ctx, data) => Offstage( "${translate("Directory")}: ${bind.mainVideoSaveDirectory(root: false)}"),
offstage: !data.hasData,
child: Text("${translate("Directory")}: ${data.data}")),
future: bind.mainVideoSaveDirectory(root: false)),
initialValue: _autoRecordIncomingSession, initialValue: _autoRecordIncomingSession,
onToggle: isOptionFixed(kOptionAllowAutoRecordIncoming) onToggle: isOptionFixed(kOptionAllowAutoRecordIncoming)
? null ? null
@@ -595,13 +703,24 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
), ),
], ],
), ),
if (isAndroid && !disabledSettings && !outgoingOnly) if (isAndroid &&
!disabledSettings &&
!outgoingOnly &&
!hideSecuritySettings)
SettingsSection(title: Text('2FA'), tiles: tfaTiles),
if (isAndroid &&
!disabledSettings &&
!outgoingOnly &&
!hideSecuritySettings)
SettingsSection( SettingsSection(
title: Text(translate("Share Screen")), title: Text(translate("Share Screen")),
tiles: shareScreenTiles, tiles: shareScreenTiles,
), ),
if (!bind.isIncomingOnly()) defaultDisplaySection(), if (!bind.isIncomingOnly()) defaultDisplaySection(),
if (isAndroid && !disabledSettings && !outgoingOnly) if (isAndroid &&
!disabledSettings &&
!outgoingOnly &&
!hideSecuritySettings)
SettingsSection( SettingsSection(
title: Text(translate("Enhancements")), title: Text(translate("Enhancements")),
tiles: enhancementsTiles, tiles: enhancementsTiles,
@@ -756,7 +875,7 @@ void showThemeSettings(OverlayDialogManager dialogManager) async {
void showAbout(OverlayDialogManager dialogManager) { void showAbout(OverlayDialogManager dialogManager) {
dialogManager.show((setState, close, context) { dialogManager.show((setState, close, context) {
return CustomAlertDialog( return CustomAlertDialog(
title: Text('${translate('About')} RustDesk'), title: Text(translate('About RustDesk')),
content: Wrap(direction: Axis.vertical, spacing: 12, children: [ content: Wrap(direction: Axis.vertical, spacing: 12, children: [
Text('Version: $version'), Text('Version: $version'),
InkWell( InkWell(
@@ -910,6 +1029,51 @@ class __DisplayPageState extends State<_DisplayPage> {
} }
} }
class _ManageTrustedDevices extends StatefulWidget {
const _ManageTrustedDevices();
@override
State<_ManageTrustedDevices> createState() => __ManageTrustedDevicesState();
}
class __ManageTrustedDevicesState extends State<_ManageTrustedDevices> {
RxList<TrustedDevice> trustedDevices = RxList.empty(growable: true);
RxList<Uint8List> selectedDevices = RxList.empty();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(translate('Manage trusted devices')),
centerTitle: true,
actions: [
Obx(() => IconButton(
icon: Icon(Icons.delete, color: Colors.white),
onPressed: selectedDevices.isEmpty
? null
: () {
confrimDeleteTrustedDevicesDialog(
trustedDevices, selectedDevices);
}))
],
),
body: FutureBuilder(
future: TrustedDevice.get(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final devices = snapshot.data as List<TrustedDevice>;
trustedDevices = devices.obs;
return trustedDevicesTable(trustedDevices, selectedDevices);
}),
);
}
}
class _RadioEntry { class _RadioEntry {
final String label; final String label;
final String value; final String value;
@@ -919,7 +1083,7 @@ class _RadioEntry {
typedef _RadioEntryGetter = String Function(); typedef _RadioEntryGetter = String Function();
typedef _RadioEntrySetter = Future<void> Function(String); typedef _RadioEntrySetter = Future<void> Function(String);
_getPopupDialogRadioEntry({ SettingsTile _getPopupDialogRadioEntry({
required String title, required String title,
required List<_RadioEntry> list, required List<_RadioEntry> list,
required _RadioEntryGetter getter, required _RadioEntryGetter getter,
@@ -973,7 +1137,7 @@ _getPopupDialogRadioEntry({
return SettingsTile( return SettingsTile(
title: Text(translate(title)), title: Text(translate(title)),
onPressed: (context) => showDialog(), onPressed: asyncSetter == null ? null : (context) => showDialog(),
value: Padding( value: Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),
child: Obx(() => Text(translate(valueText.value))), child: Obx(() => Text(translate(valueText.value))),

View File

@@ -205,6 +205,7 @@ void showServerSettingsWithValue(
) )
] + ] +
[ [
if (isAndroid)
TextFormField( TextFormField(
controller: relayCtrl, controller: relayCtrl,
decoration: InputDecoration( decoration: InputDecoration(

View File

@@ -41,18 +41,16 @@ class GestureHelp extends StatefulWidget {
final OnTouchModeChange onTouchModeChange; final OnTouchModeChange onTouchModeChange;
@override @override
State<StatefulWidget> createState() => _GestureHelpState(); State<StatefulWidget> createState() => _GestureHelpState(touchMode);
} }
class _GestureHelpState extends State<GestureHelp> { class _GestureHelpState extends State<GestureHelp> {
var _selectedIndex; late int _selectedIndex;
var _touchMode; late bool _touchMode;
@override _GestureHelpState(bool touchMode) {
void initState() { _touchMode = touchMode;
_touchMode = widget.touchMode;
_selectedIndex = _touchMode ? 1 : 0; _selectedIndex = _touchMode ? 1 : 0;
super.initState();
} }
@override @override

View File

@@ -17,17 +17,17 @@ import '../common.dart';
final syncAbOption = 'sync-ab-with-recent-sessions'; final syncAbOption = 'sync-ab-with-recent-sessions';
bool shouldSyncAb() { bool shouldSyncAb() {
return bind.mainGetLocalOption(key: syncAbOption).isNotEmpty; return bind.mainGetLocalOption(key: syncAbOption) == 'Y';
} }
final sortAbTagsOption = 'sync-ab-tags'; final sortAbTagsOption = 'sync-ab-tags';
bool shouldSortTags() { bool shouldSortTags() {
return bind.mainGetLocalOption(key: sortAbTagsOption).isNotEmpty; return bind.mainGetLocalOption(key: sortAbTagsOption) == 'Y';
} }
final filterAbTagOption = 'filter-ab-by-intersection'; final filterAbTagOption = 'filter-ab-by-intersection';
bool filterAbTagByIntersection() { bool filterAbTagByIntersection() {
return bind.mainGetLocalOption(key: filterAbTagOption).isNotEmpty; return bind.mainGetLocalOption(key: filterAbTagOption) == 'Y';
} }
const _personalAddressBookName = "My address book"; const _personalAddressBookName = "My address book";
@@ -66,10 +66,16 @@ class AbModel {
var listInitialized = false; var listInitialized = false;
var _maxPeerOneAb = 0; var _maxPeerOneAb = 0;
late final Peers peersModel;
WeakReference<FFI> parent; WeakReference<FFI> parent;
AbModel(this.parent) { AbModel(this.parent) {
addressbooks.clear(); addressbooks.clear();
peersModel = Peers(
name: PeersModelName.addressBook,
getInitPeers: () => currentAbPeers,
loadEvent: LoadEvent.addressBook);
if (desktopType == DesktopType.main) { if (desktopType == DesktopType.main) {
Timer.periodic(Duration(milliseconds: 500), (timer) async { Timer.periodic(Duration(milliseconds: 500), (timer) async {
if (_timerCounter++ % 6 == 0) { if (_timerCounter++ % 6 == 0) {
@@ -111,9 +117,10 @@ class AbModel {
Future<void> _pullAb( Future<void> _pullAb(
{required ForcePullAb? force, required bool quiet}) async { {required ForcePullAb? force, required bool quiet}) async {
if (bind.isDisableAb()) return; if (bind.isDisableAb()) return;
debugPrint("pullAb, force: $force, quiet: $quiet");
if (!gFFI.userModel.isLogin) return; if (!gFFI.userModel.isLogin) return;
if (gFFI.userModel.networkError.isNotEmpty) return;
if (force == null && listInitialized && current.initialized) return; if (force == null && listInitialized && current.initialized) return;
debugPrint("pullAb, force: $force, quiet: $quiet");
if (!listInitialized || force == ForcePullAb.listAndCurrent) { if (!listInitialized || force == ForcePullAb.listAndCurrent) {
try { try {
// Read personal guid every time to avoid upgrading the server without closing the main window // Read personal guid every time to avoid upgrading the server without closing the main window
@@ -815,8 +822,6 @@ abstract class BaseAb {
} }
class LegacyAb extends BaseAb { class LegacyAb extends BaseAb {
final sortTags = shouldSortTags().obs;
final filterByIntersection = filterAbTagByIntersection().obs;
bool get emtpy => peers.isEmpty && tags.isEmpty; bool get emtpy => peers.isEmpty && tags.isEmpty;
// licensedDevices is obtained from personal ab, shared ab restrict it in server // licensedDevices is obtained from personal ab, shared ab restrict it in server
var licensedDevices = 0; var licensedDevices = 0;
@@ -1209,8 +1214,6 @@ class LegacyAb extends BaseAb {
class Ab extends BaseAb { class Ab extends BaseAb {
AbProfile profile; AbProfile profile;
late final bool personal; late final bool personal;
final sortTags = shouldSortTags().obs;
final filterByIntersection = filterAbTagByIntersection().obs;
bool get emtpy => peers.isEmpty && tags.isEmpty; bool get emtpy => peers.isEmpty && tags.isEmpty;
Ab(this.profile, this.personal); Ab(this.profile, this.personal);

View File

@@ -235,13 +235,14 @@ class ChatModel with ChangeNotifier {
} }
} }
_isChatOverlayHide() => ((!isDesktop && chatIconOverlayEntry == null) || _isChatOverlayHide() =>
((!(isDesktop || isWebDesktop) && chatIconOverlayEntry == null) ||
chatWindowOverlayEntry == null); chatWindowOverlayEntry == null);
toggleChatOverlay({Offset? chatInitPos}) { toggleChatOverlay({Offset? chatInitPos}) {
if (_isChatOverlayHide()) { if (_isChatOverlayHide()) {
gFFI.invokeMethod("enable_soft_keyboard", true); gFFI.invokeMethod("enable_soft_keyboard", true);
if (!isDesktop) { if (!(isDesktop || isWebDesktop)) {
showChatIconOverlay(); showChatIconOverlay();
} }
showChatWindowOverlay(chatInitPos: chatInitPos); showChatWindowOverlay(chatInitPos: chatInitPos);
@@ -535,6 +536,8 @@ class ChatModel with ChangeNotifier {
void onVoiceCallClosed(String reason) { void onVoiceCallClosed(String reason) {
_voiceCallStatus.value = VoiceCallStatus.notStarted; _voiceCallStatus.value = VoiceCallStatus.notStarted;
if (isAndroid) { if (isAndroid) {
// We can always invoke "on_voice_call_closed"
// no matter if the `_voiceCallStatus` was `VoiceCallStatus.notStarted` or not.
parent.target?.invokeMethod("on_voice_call_closed"); parent.target?.invokeMethod("on_voice_call_closed");
} }
} }

View File

@@ -33,6 +33,8 @@ class CmFileModel {
_onFileRemove(evt['remove']); _onFileRemove(evt['remove']);
} else if (evt['create_dir'] != null) { } else if (evt['create_dir'] != null) {
_onDirCreate(evt['create_dir']); _onDirCreate(evt['create_dir']);
} else if (evt['rename'] != null) {
_onRename(evt['rename']);
} }
} }
@@ -59,8 +61,6 @@ class CmFileModel {
_dealOneJob(dynamic l, bool calcSpeed) { _dealOneJob(dynamic l, bool calcSpeed) {
final data = TransferJobSerdeData.fromJson(l); final data = TransferJobSerdeData.fromJson(l);
Client? client =
gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == data.connId);
var jobTable = _jobTables[data.connId]; var jobTable = _jobTables[data.connId];
if (jobTable == null) { if (jobTable == null) {
debugPrint("jobTable should not be null"); debugPrint("jobTable should not be null");
@@ -70,12 +70,7 @@ class CmFileModel {
if (job == null) { if (job == null) {
job = CmFileLog(); job = CmFileLog();
jobTable.add(job); jobTable.add(job);
final currentSelectedTab = _addUnread(data.connId);
gFFI.serverModel.tabController.state.value.selectedTabInfo;
if (!(gFFI.chatModel.isShowCMSidePage &&
currentSelectedTab.key == data.connId.toString())) {
client?.unreadChatMessageCount.value += 1;
}
} }
job.id = data.id; job.id = data.id;
job.action = job.action =
@@ -167,8 +162,6 @@ class CmFileModel {
try { try {
dynamic d = jsonDecode(log); dynamic d = jsonDecode(log);
FileActionLog data = FileActionLog.fromJson(d); FileActionLog data = FileActionLog.fromJson(d);
Client? client =
gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == data.connId);
var jobTable = _jobTables[data.connId]; var jobTable = _jobTables[data.connId];
if (jobTable == null) { if (jobTable == null) {
debugPrint("jobTable should not be null"); debugPrint("jobTable should not be null");
@@ -179,17 +172,45 @@ class CmFileModel {
..fileName = data.path ..fileName = data.path
..action = CmFileAction.createDir ..action = CmFileAction.createDir
..state = JobState.done); ..state = JobState.done);
final currentSelectedTab = _addUnread(data.connId);
gFFI.serverModel.tabController.state.value.selectedTabInfo;
if (!(gFFI.chatModel.isShowCMSidePage &&
currentSelectedTab.key == data.connId.toString())) {
client?.unreadChatMessageCount.value += 1;
}
jobTable.refresh(); jobTable.refresh();
} catch (e) { } catch (e) {
debugPrint('$e'); debugPrint('$e');
} }
} }
_onRename(dynamic log) {
try {
dynamic d = jsonDecode(log);
FileRenamenLog data = FileRenamenLog.fromJson(d);
var jobTable = _jobTables[data.connId];
if (jobTable == null) {
debugPrint("jobTable should not be null");
return;
}
final fileName = '${data.path} -> ${data.newName}';
jobTable.add(CmFileLog()
..id = 0
..fileName = fileName
..action = CmFileAction.rename
..state = JobState.done);
_addUnread(data.connId);
jobTable.refresh();
} catch (e) {
debugPrint('$e');
}
}
_addUnread(int connId) {
Client? client =
gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == connId);
final currentSelectedTab =
gFFI.serverModel.tabController.state.value.selectedTabInfo;
if (!(gFFI.chatModel.isShowCMSidePage &&
currentSelectedTab.key == connId.toString())) {
client?.unreadChatMessageCount.value += 1;
}
}
} }
enum CmFileAction { enum CmFileAction {
@@ -198,6 +219,7 @@ enum CmFileAction {
localToRemote, localToRemote,
remove, remove,
createDir, createDir,
rename,
} }
class CmFileLog { class CmFileLog {
@@ -285,3 +307,22 @@ class FileActionLog {
dir: d['dir'] ?? false, dir: d['dir'] ?? false,
); );
} }
class FileRenamenLog {
int connId = 0;
String path = '';
String newName = '';
FileRenamenLog({
required this.connId,
required this.path,
required this.newName,
});
FileRenamenLog.fromJson(dynamic d)
: this(
connId: d['connId'] ?? 0,
path: d['path'] ?? '',
newName: d['newName'] ?? '',
);
}

View File

@@ -181,6 +181,7 @@ class TextureModel {
} }
updateCurrentDisplay(int curDisplay) { updateCurrentDisplay(int curDisplay) {
if (isWeb) return;
final ffi = parent.target; final ffi = parent.target;
if (ffi == null) return; if (ffi == null) return;
tryCreateTexture(int idx) { tryCreateTexture(int idx) {

View File

@@ -3,9 +3,12 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/utils/event_loop.dart'; import 'package:flutter_hbb/utils/event_loop.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:flutter_hbb/web/dummy.dart'
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
import '../consts.dart'; import '../consts.dart';
import 'model.dart'; import 'model.dart';
@@ -33,6 +36,7 @@ class JobID {
} }
typedef GetSessionID = SessionID Function(); typedef GetSessionID = SessionID Function();
typedef GetDialogManager = OverlayDialogManager? Function();
class FileModel { class FileModel {
final WeakReference<FFI> parent; final WeakReference<FFI> parent;
@@ -44,13 +48,15 @@ class FileModel {
late final FileController remoteController; late final FileController remoteController;
late final GetSessionID getSessionID; late final GetSessionID getSessionID;
late final GetDialogManager getDialogManager;
SessionID get sessionId => getSessionID(); SessionID get sessionId => getSessionID();
late final FileDialogEventLoop evtLoop; late final FileDialogEventLoop evtLoop;
FileModel(this.parent) { FileModel(this.parent) {
getSessionID = () => parent.target!.sessionId; getSessionID = () => parent.target!.sessionId;
getDialogManager = () => parent.target?.dialogManager;
fileFetcher = FileFetcher(getSessionID); fileFetcher = FileFetcher(getSessionID);
jobController = JobController(getSessionID); jobController = JobController(getSessionID, getDialogManager);
localController = FileController( localController = FileController(
isLocal: true, isLocal: true,
getSessionID: getSessionID, getSessionID: getSessionID,
@@ -70,7 +76,7 @@ class FileModel {
Future<void> onReady() async { Future<void> onReady() async {
await evtLoop.onReady(); await evtLoop.onReady();
await localController.onReady(); if (!isWeb) await localController.onReady();
await remoteController.onReady(); await remoteController.onReady();
} }
@@ -82,7 +88,7 @@ class FileModel {
} }
Future<void> refreshAll() async { Future<void> refreshAll() async {
await localController.refresh(); if (!isWeb) await localController.refresh();
await remoteController.refresh(); await remoteController.refresh();
} }
@@ -224,6 +230,33 @@ class FileModel {
); );
}, useAnimation: false); }, useAnimation: false);
} }
void onSelectedFiles(dynamic obj) {
localController.selectedItems.clear();
try {
int handleIndex = int.parse(obj['handleIndex']);
final file = jsonDecode(obj['file']);
var entry = Entry.fromJson(file);
entry.path = entry.name;
final otherSideData = remoteController.directoryData();
final toPath = otherSideData.directory.path;
final isWindows = otherSideData.options.isWindows;
final showHidden = otherSideData.options.showHidden;
final jobID = jobController.addTransferJob(entry, false);
webSendLocalFiles(
handleIndex: handleIndex,
actId: jobID,
path: entry.path,
to: PathUtil.join(toPath, entry.name, isWindows),
fileNum: 0,
includeHidden: showHidden,
isRemote: false,
);
} catch (e) {
debugPrint("Failed to decode onSelectedFiles: $e");
}
}
} }
class DirectoryData { class DirectoryData {
@@ -450,7 +483,7 @@ class FileController {
final isWindows = otherSideData.options.isWindows; final isWindows = otherSideData.options.isWindows;
final showHidden = otherSideData.options.showHidden; final showHidden = otherSideData.options.showHidden;
for (var from in items.items) { for (var from in items.items) {
final jobID = jobController.add(from, isRemoteToLocal); final jobID = jobController.addTransferJob(from, isRemoteToLocal);
bind.sessionSendFiles( bind.sessionSendFiles(
sessionId: sessionId, sessionId: sessionId,
actId: jobID, actId: jobID,
@@ -458,7 +491,8 @@ class FileController {
to: PathUtil.join(toPath, from.name, isWindows), to: PathUtil.join(toPath, from.name, isWindows),
fileNum: 0, fileNum: 0,
includeHidden: showHidden, includeHidden: showHidden,
isRemote: isRemoteToLocal); isRemote: isRemoteToLocal,
isDir: from.isDirectory);
debugPrint( debugPrint(
"path: ${from.path}, toPath: $toPath, to: ${PathUtil.join(toPath, from.name, isWindows)}"); "path: ${from.path}, toPath: $toPath, to: ${PathUtil.join(toPath, from.name, isWindows)}");
} }
@@ -485,7 +519,7 @@ class FileController {
} else if (item.isDirectory) { } else if (item.isDirectory) {
title = translate("Not an empty directory"); title = translate("Not an empty directory");
dialogManager?.showLoading(translate("Waiting")); dialogManager?.showLoading(translate("Waiting"));
final fd = await fileFetcher.fetchDirectoryRecursive( final fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
jobID, item.path, items.isLocal, true); jobID, item.path, items.isLocal, true);
if (fd.path.isEmpty) { if (fd.path.isEmpty) {
fd.path = item.path; fd.path = item.path;
@@ -493,13 +527,21 @@ class FileController {
fd.format(isWindows); fd.format(isWindows);
dialogManager?.dismissAll(); dialogManager?.dismissAll();
if (fd.entries.isEmpty) { if (fd.entries.isEmpty) {
var deleteJobId = jobController.addDeleteDirJob(item, !isLocal, 0);
final confirm = await showRemoveDialog( final confirm = await showRemoveDialog(
translate( translate(
"Are you sure you want to delete this empty directory?"), "Are you sure you want to delete this empty directory?"),
item.name, item.name,
false); false);
if (confirm == true) { if (confirm == true) {
sendRemoveEmptyDir(item.path, 0); sendRemoveEmptyDir(
item.path,
0,
deleteJobId,
);
} else {
jobController.updateJobStatus(deleteJobId,
error: "cancel", state: JobState.done);
} }
return; return;
} }
@@ -507,6 +549,13 @@ class FileController {
} else { } else {
entries = []; entries = [];
} }
int deleteJobId;
if (item.isDirectory) {
deleteJobId =
jobController.addDeleteDirJob(item, !isLocal, entries.length);
} else {
deleteJobId = jobController.addDeleteFileJob(item, !isLocal);
}
for (var i = 0; i < entries.length; i++) { for (var i = 0; i < entries.length; i++) {
final dirShow = item.isDirectory final dirShow = item.isDirectory
@@ -521,24 +570,32 @@ class FileController {
); );
try { try {
if (confirm == true) { if (confirm == true) {
sendRemoveFile(entries[i].path, i); sendRemoveFile(entries[i].path, i, deleteJobId);
final res = await jobController.jobResultListener.start(); final res = await jobController.jobResultListener.start();
// handle remove res; // handle remove res;
if (item.isDirectory && if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) { res['file_num'] == (entries.length - 1).toString()) {
sendRemoveEmptyDir(item.path, i); sendRemoveEmptyDir(item.path, i, deleteJobId);
} }
} else {
jobController.updateJobStatus(deleteJobId,
file_num: i, error: "cancel");
} }
if (_removeCheckboxRemember) { if (_removeCheckboxRemember) {
if (confirm == true) { if (confirm == true) {
for (var j = i + 1; j < entries.length; j++) { for (var j = i + 1; j < entries.length; j++) {
sendRemoveFile(entries[j].path, j); sendRemoveFile(entries[j].path, j, deleteJobId);
final res = await jobController.jobResultListener.start(); final res = await jobController.jobResultListener.start();
if (item.isDirectory && if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) { res['file_num'] == (entries.length - 1).toString()) {
sendRemoveEmptyDir(item.path, i); sendRemoveEmptyDir(item.path, i, deleteJobId);
} }
} }
} else {
jobController.updateJobStatus(deleteJobId,
error: "cancel",
file_num: entries.length,
state: JobState.done);
} }
break; break;
} }
@@ -617,22 +674,19 @@ class FileController {
}, useAnimation: false); }, useAnimation: false);
} }
void sendRemoveFile(String path, int fileNum) { void sendRemoveFile(String path, int fileNum, int actId) {
bind.sessionRemoveFile( bind.sessionRemoveFile(
sessionId: sessionId, sessionId: sessionId,
actId: JobController.jobID.next(), actId: actId,
path: path, path: path,
isRemote: !isLocal, isRemote: !isLocal,
fileNum: fileNum); fileNum: fileNum);
} }
void sendRemoveEmptyDir(String path, int fileNum) { void sendRemoveEmptyDir(String path, int fileNum, int actId) {
history.removeWhere((element) => element.contains(path)); history.removeWhere((element) => element.contains(path));
bind.sessionRemoveAllEmptyDirs( bind.sessionRemoveAllEmptyDirs(
sessionId: sessionId, sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
actId: JobController.jobID.next(),
path: path,
isRemote: !isLocal);
} }
Future<void> createDir(String path) async { Future<void> createDir(String path) async {
@@ -642,29 +696,102 @@ class FileController {
path: path, path: path,
isRemote: !isLocal); isRemote: !isLocal);
} }
Future<void> renameAction(Entry item, bool isLocal) async {
final textEditingController = TextEditingController(text: item.name);
String? errorText;
dialogManager?.show((setState, close, context) {
textEditingController.addListener(() {
if (errorText != null) {
setState(() {
errorText = null;
});
}
});
submit() async {
final newName = textEditingController.text;
if (newName.isEmpty || newName == item.name) {
close();
return;
}
if (directory.value.entries.any((e) => e.name == newName)) {
setState(() {
errorText = translate("Already exists");
});
return;
}
if (!PathUtil.validName(newName, options.value.isWindows)) {
setState(() {
if (item.isDirectory) {
errorText = translate("Invalid folder name");
} else {
errorText = translate("Invalid file name");
}
});
return;
}
await bind.sessionRenameFile(
sessionId: sessionId,
actId: JobController.jobID.next(),
path: item.path,
newName: newName,
isRemote: !isLocal);
close();
}
return CustomAlertDialog(
content: Column(
children: [
DialogTextField(
title: '${translate('Rename')} ${item.name}',
controller: textEditingController,
errorText: errorText,
),
],
),
actions: [
dialogButton(
"Cancel",
icon: Icon(Icons.close_rounded),
onPressed: close,
isOutline: true,
),
dialogButton(
"OK",
icon: Icon(Icons.done_rounded),
onPressed: submit,
),
],
onSubmit: submit,
onCancel: close,
);
});
}
} }
const _kOneWayFileTransferError = 'one-way-file-transfer-tip';
class JobController { class JobController {
static final JobID jobID = JobID(); static final JobID jobID = JobID();
final jobTable = List<JobProgress>.empty(growable: true).obs; final jobTable = List<JobProgress>.empty(growable: true).obs;
final jobResultListener = JobResultListener<Map<String, dynamic>>(); final jobResultListener = JobResultListener<Map<String, dynamic>>();
final GetSessionID getSessionID; final GetSessionID getSessionID;
final GetDialogManager getDialogManager;
SessionID get sessionId => getSessionID(); SessionID get sessionId => getSessionID();
OverlayDialogManager? get alogManager => getDialogManager();
int _lastTimeShowMsgbox = DateTime.now().millisecondsSinceEpoch;
JobController(this.getSessionID); JobController(this.getSessionID, this.getDialogManager);
int getJob(int id) { int getJob(int id) {
return jobTable.indexWhere((element) => element.id == id); return jobTable.indexWhere((element) => element.id == id);
} }
// JobProgress? getJob(int id) {
// return jobTable.firstWhere((element) => element.id == id);
// }
// return jobID // return jobID
int add(Entry from, bool isRemoteToLocal) { int addTransferJob(Entry from, bool isRemoteToLocal) {
final jobID = JobController.jobID.next(); final jobID = JobController.jobID.next();
jobTable.add(JobProgress() jobTable.add(JobProgress()
..type = JobType.transfer
..fileName = path.basename(from.path) ..fileName = path.basename(from.path)
..jobName = from.path ..jobName = from.path
..totalSize = from.size ..totalSize = from.size
@@ -674,6 +801,33 @@ class JobController {
return jobID; return jobID;
} }
int addDeleteFileJob(Entry file, bool isRemote) {
final jobID = JobController.jobID.next();
jobTable.add(JobProgress()
..type = JobType.deleteFile
..fileName = path.basename(file.path)
..jobName = file.path
..totalSize = file.size
..state = JobState.none
..id = jobID
..isRemoteToLocal = isRemote);
return jobID;
}
int addDeleteDirJob(Entry file, bool isRemote, int fileCount) {
final jobID = JobController.jobID.next();
jobTable.add(JobProgress()
..type = JobType.deleteDir
..fileName = path.basename(file.path)
..jobName = file.path
..fileCount = fileCount
..totalSize = file.size
..state = JobState.none
..id = jobID
..isRemoteToLocal = isRemote);
return jobID;
}
void tryUpdateJobProgress(Map<String, dynamic> evt) { void tryUpdateJobProgress(Map<String, dynamic> evt) {
try { try {
int id = int.parse(evt['id']); int id = int.parse(evt['id']);
@@ -684,7 +838,7 @@ class JobController {
job.fileNum = int.parse(evt['file_num']); job.fileNum = int.parse(evt['file_num']);
job.speed = double.parse(evt['speed']); job.speed = double.parse(evt['speed']);
job.finishedSize = int.parse(evt['finished_size']); job.finishedSize = int.parse(evt['finished_size']);
debugPrint("update job $id with $evt"); job.recvJobRes = true;
jobTable.refresh(); jobTable.refresh();
} }
} catch (e) { } catch (e) {
@@ -692,20 +846,48 @@ class JobController {
} }
} }
void jobDone(Map<String, dynamic> evt) async { Future<bool> jobDone(Map<String, dynamic> evt) async {
if (jobResultListener.isListening) { if (jobResultListener.isListening) {
jobResultListener.complete(evt); jobResultListener.complete(evt);
return; // return;
} }
int id = -1;
int id = int.parse(evt['id']); int? fileNum = 0;
double? speed = 0;
try {
id = int.parse(evt['id']);
} catch (_) {}
final jobIndex = getJob(id); final jobIndex = getJob(id);
if (jobIndex != -1) { if (jobIndex == -1) return true;
final job = jobTable[jobIndex]; final job = jobTable[jobIndex];
job.finishedSize = job.totalSize; job.recvJobRes = true;
if (job.type == JobType.deleteFile) {
job.state = JobState.done; job.state = JobState.done;
job.fileNum = int.parse(evt['file_num']); } else if (job.type == JobType.deleteDir) {
try {
fileNum = int.tryParse(evt['file_num']);
} catch (_) {}
if (fileNum != null) {
if (fileNum < job.fileNum) return true; // file_num can be 0 at last
job.fileNum = fileNum;
if (fileNum >= job.fileCount - 1) {
job.state = JobState.done;
}
}
} else {
try {
fileNum = int.tryParse(evt['file_num']);
speed = double.tryParse(evt['speed']);
} catch (_) {}
if (fileNum != null) job.fileNum = fileNum;
if (speed != null) job.speed = speed;
job.state = JobState.done;
}
jobTable.refresh(); jobTable.refresh();
if (job.type == JobType.deleteDir) {
return job.state == JobState.done;
} else {
return true;
} }
} }
@@ -716,16 +898,61 @@ class JobController {
final job = jobTable[jobIndex]; final job = jobTable[jobIndex];
job.state = JobState.error; job.state = JobState.error;
job.err = err; job.err = err;
job.fileNum = int.parse(evt['file_num']); job.recvJobRes = true;
if (job.type == JobType.transfer) {
int? fileNum = int.tryParse(evt['file_num']);
if (fileNum != null) job.fileNum = fileNum;
if (err == "skipped") { if (err == "skipped") {
job.state = JobState.done; job.state = JobState.done;
job.finishedSize = job.totalSize; job.finishedSize = job.totalSize;
} }
} else if (job.type == JobType.deleteDir) {
if (jobResultListener.isListening) {
jobResultListener.complete(evt);
}
int? fileNum = int.tryParse(evt['file_num']);
if (fileNum != null) job.fileNum = fileNum;
} else if (job.type == JobType.deleteFile) {
if (jobResultListener.isListening) {
jobResultListener.complete(evt);
}
}
jobTable.refresh(); jobTable.refresh();
} }
if (err == _kOneWayFileTransferError) {
if (DateTime.now().millisecondsSinceEpoch - _lastTimeShowMsgbox > 3000) {
final dm = alogManager;
if (dm != null) {
_lastTimeShowMsgbox = DateTime.now().millisecondsSinceEpoch;
msgBox(sessionId, 'custom-nocancel', 'Error', err, '', dm);
}
}
}
debugPrint("jobError $evt"); debugPrint("jobError $evt");
} }
void updateJobStatus(int id,
{int? file_num, String? error, JobState? state}) {
final jobIndex = getJob(id);
if (jobIndex < 0) return;
final job = jobTable[jobIndex];
job.recvJobRes = true;
if (file_num != null) {
job.fileNum = file_num;
}
if (error != null) {
job.err = error;
job.state = JobState.error;
}
if (state != null) {
job.state = state;
}
if (job.type == JobType.deleteFile && error == null) {
job.state = JobState.done;
}
jobTable.refresh();
}
Future<void> cancelJob(int id) async { Future<void> cancelJob(int id) async {
await bind.sessionCancelJob(sessionId: sessionId, actId: id); await bind.sessionCancelJob(sessionId: sessionId, actId: id);
} }
@@ -742,6 +969,7 @@ class JobController {
final currJobId = JobController.jobID.next(); final currJobId = JobController.jobID.next();
String fileName = path.basename(isRemote ? remote : to); String fileName = path.basename(isRemote ? remote : to);
var jobProgress = JobProgress() var jobProgress = JobProgress()
..type = JobType.transfer
..fileName = fileName ..fileName = fileName
..jobName = isRemote ? remote : to ..jobName = isRemote ? remote : to
..id = currJobId ..id = currJobId
@@ -917,11 +1145,11 @@ class FileFetcher {
} }
} }
Future<FileDirectory> fetchDirectoryRecursive( Future<FileDirectory> fetchDirectoryRecursiveToRemove(
int actID, String path, bool isLocal, bool showHidden) async { int actID, String path, bool isLocal, bool showHidden) async {
// TODO test Recursive is show hidden default? // TODO test Recursive is show hidden default?
try { try {
await bind.sessionReadDirRecursive( await bind.sessionReadDirToRemoveRecursive(
sessionId: sessionId, sessionId: sessionId,
actId: actID, actId: actID,
path: path, path: path,
@@ -1016,8 +1244,12 @@ extension JobStateDisplay on JobState {
} }
} }
enum JobType { none, transfer, deleteFile, deleteDir }
class JobProgress { class JobProgress {
JobType type = JobType.none;
JobState state = JobState.none; JobState state = JobState.none;
var recvJobRes = false;
var id = 0; var id = 0;
var fileNum = 0; var fileNum = 0;
var speed = 0.0; var speed = 0.0;
@@ -1037,7 +1269,9 @@ class JobProgress {
int lastTransferredSize = 0; int lastTransferredSize = 0;
clear() { clear() {
type = JobType.none;
state = JobState.none; state = JobState.none;
recvJobRes = false;
id = 0; id = 0;
fileNum = 0; fileNum = 0;
speed = 0; speed = 0;
@@ -1051,11 +1285,81 @@ class JobProgress {
} }
String display() { String display() {
if (type == JobType.transfer) {
if (state == JobState.done && err == "skipped") { if (state == JobState.done && err == "skipped") {
return translate("Skipped"); return translate("Skipped");
} }
} else if (type == JobType.deleteFile) {
if (err == "cancel") {
return translate("Cancel");
}
}
return state.display(); return state.display();
} }
String getStatus() {
int handledFileCount = recvJobRes ? fileNum + 1 : fileNum;
if (handledFileCount >= fileCount) {
handledFileCount = fileCount;
}
if (state == JobState.done) {
handledFileCount = fileCount;
finishedSize = totalSize;
}
final filesStr = "$handledFileCount/$fileCount files";
final sizeStr = totalSize > 0 ? readableFileSize(totalSize.toDouble()) : "";
final sizePercentStr = totalSize > 0 && finishedSize > 0
? "${readableFileSize(finishedSize.toDouble())} / ${readableFileSize(totalSize.toDouble())}"
: "";
if (type == JobType.deleteFile) {
return display();
} else if (type == JobType.deleteDir) {
var res = '';
if (state == JobState.done || state == JobState.error) {
res = display();
}
if (filesStr.isNotEmpty) {
if (res.isNotEmpty) {
res += " ";
}
res += filesStr;
}
if (sizeStr.isNotEmpty) {
if (res.isNotEmpty) {
res += ", ";
}
res += sizeStr;
}
return res;
} else if (type == JobType.transfer) {
var res = "";
if (state != JobState.inProgress && state != JobState.none) {
res += display();
}
if (filesStr.isNotEmpty) {
if (res.isNotEmpty) {
res += ", ";
}
res += filesStr;
}
if (sizeStr.isNotEmpty && state != JobState.inProgress) {
if (res.isNotEmpty) {
res += ", ";
}
res += sizeStr;
}
if (sizePercentStr.isNotEmpty && state == JobState.inProgress) {
if (res.isNotEmpty) {
res += ", ";
}
res += sizePercentStr;
}
return res;
}
return '';
}
} }
class _PathStat { class _PathStat {
@@ -1083,6 +1387,13 @@ class PathUtil {
final pathUtil = isWindows ? windowsContext : posixContext; final pathUtil = isWindows ? windowsContext : posixContext;
return pathUtil.dirname(path); return pathUtil.dirname(path);
} }
static bool validName(String name, bool isWindows) {
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
return reg.hasMatch(name);
}
} }
class DirectoryOptions { class DirectoryOptions {

View File

@@ -23,11 +23,19 @@ class GroupModel {
bool get emtpy => users.isEmpty && peers.isEmpty; bool get emtpy => users.isEmpty && peers.isEmpty;
GroupModel(this.parent); late final Peers peersModel;
GroupModel(this.parent) {
peersModel = Peers(
name: PeersModelName.group,
getInitPeers: () => peers,
loadEvent: LoadEvent.group);
}
Future<void> pull({force = true, quiet = false}) async { Future<void> pull({force = true, quiet = false}) async {
if (bind.isDisableGroupPanel()) return; if (bind.isDisableGroupPanel()) return;
if (!gFFI.userModel.isLogin || groupLoading.value) return; if (!gFFI.userModel.isLogin || groupLoading.value) return;
if (gFFI.userModel.networkError.isNotEmpty) return;
if (!force && initialized) return; if (!force && initialized) return;
if (!quiet) { if (!quiet) {
groupLoading.value = true; groupLoading.value = true;

View File

@@ -177,7 +177,7 @@ class PointerEventToRust {
} }
} }
class ToReleaseKeys { class ToReleaseRawKeys {
RawKeyEvent? lastLShiftKeyEvent; RawKeyEvent? lastLShiftKeyEvent;
RawKeyEvent? lastRShiftKeyEvent; RawKeyEvent? lastRShiftKeyEvent;
RawKeyEvent? lastLCtrlKeyEvent; RawKeyEvent? lastLCtrlKeyEvent;
@@ -282,6 +282,48 @@ class ToReleaseKeys {
} }
} }
class ToReleaseKeys {
KeyEvent? lastLShiftKeyEvent;
KeyEvent? lastRShiftKeyEvent;
KeyEvent? lastLCtrlKeyEvent;
KeyEvent? lastRCtrlKeyEvent;
KeyEvent? lastLAltKeyEvent;
KeyEvent? lastRAltKeyEvent;
KeyEvent? lastLCommandKeyEvent;
KeyEvent? lastRCommandKeyEvent;
KeyEvent? lastSuperKeyEvent;
reset() {
lastLShiftKeyEvent = null;
lastRShiftKeyEvent = null;
lastLCtrlKeyEvent = null;
lastRCtrlKeyEvent = null;
lastLAltKeyEvent = null;
lastRAltKeyEvent = null;
lastLCommandKeyEvent = null;
lastRCommandKeyEvent = null;
lastSuperKeyEvent = null;
}
release(KeyEventResult Function(KeyEvent e) handleKeyEvent) {
for (final key in [
lastLShiftKeyEvent,
lastRShiftKeyEvent,
lastLCtrlKeyEvent,
lastRCtrlKeyEvent,
lastLAltKeyEvent,
lastRAltKeyEvent,
lastLCommandKeyEvent,
lastRCommandKeyEvent,
lastSuperKeyEvent,
]) {
if (key != null) {
handleKeyEvent(key);
}
}
}
}
class InputModel { class InputModel {
final WeakReference<FFI> parent; final WeakReference<FFI> parent;
String keyboardMode = ''; String keyboardMode = '';
@@ -292,6 +334,7 @@ class InputModel {
var alt = false; var alt = false;
var command = false; var command = false;
final ToReleaseRawKeys toReleaseRawKeys = ToReleaseRawKeys();
final ToReleaseKeys toReleaseKeys = ToReleaseKeys(); final ToReleaseKeys toReleaseKeys = ToReleaseKeys();
// trackpad // trackpad
@@ -339,10 +382,99 @@ class InputModel {
} }
} }
void handleKeyDownEventModifiers(KeyEvent e) {
KeyUpEvent upEvent(e) => KeyUpEvent(
physicalKey: e.physicalKey,
logicalKey: e.logicalKey,
timeStamp: e.timeStamp,
);
if (e.logicalKey == LogicalKeyboardKey.altLeft) {
if (!alt) {
alt = true;
}
toReleaseKeys.lastLAltKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.altRight) {
if (!alt) {
alt = true;
}
toReleaseKeys.lastLAltKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.controlLeft) {
if (!ctrl) {
ctrl = true;
}
toReleaseKeys.lastLCtrlKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.controlRight) {
if (!ctrl) {
ctrl = true;
}
toReleaseKeys.lastRCtrlKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.shiftLeft) {
if (!shift) {
shift = true;
}
toReleaseKeys.lastLShiftKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.shiftRight) {
if (!shift) {
shift = true;
}
toReleaseKeys.lastRShiftKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.metaLeft) {
if (!command) {
command = true;
}
toReleaseKeys.lastLCommandKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.metaRight) {
if (!command) {
command = true;
}
toReleaseKeys.lastRCommandKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.superKey) {
if (!command) {
command = true;
}
toReleaseKeys.lastSuperKeyEvent = upEvent(e);
}
}
void handleKeyUpEventModifiers(KeyEvent e) {
if (e.logicalKey == LogicalKeyboardKey.altLeft) {
alt = false;
toReleaseKeys.lastLAltKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.altRight) {
alt = false;
toReleaseKeys.lastRAltKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.controlLeft) {
ctrl = false;
toReleaseKeys.lastLCtrlKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.controlRight) {
ctrl = false;
toReleaseKeys.lastRCtrlKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.shiftLeft) {
shift = false;
toReleaseKeys.lastLShiftKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.shiftRight) {
shift = false;
toReleaseKeys.lastRShiftKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.metaLeft) {
command = false;
toReleaseKeys.lastLCommandKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.metaRight) {
command = false;
toReleaseKeys.lastRCommandKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.superKey) {
command = false;
toReleaseKeys.lastSuperKeyEvent = null;
}
}
KeyEventResult handleRawKeyEvent(RawKeyEvent e) { KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
if (isViewOnly) return KeyEventResult.handled; if (isViewOnly) return KeyEventResult.handled;
if ((isDesktop || isWebDesktop) && !isInputSourceFlutter) { if (!isInputSourceFlutter) {
if (isDesktop) {
return KeyEventResult.handled; return KeyEventResult.handled;
} else if (isWeb) {
return KeyEventResult.ignored;
}
} }
final key = e.logicalKey; final key = e.logicalKey;
@@ -358,7 +490,7 @@ class InputModel {
command = true; command = true;
} }
} }
toReleaseKeys.updateKeyDown(key, e); toReleaseRawKeys.updateKeyDown(key, e);
} }
if (e is RawKeyUpEvent) { if (e is RawKeyUpEvent) {
if (key == LogicalKeyboardKey.altLeft || if (key == LogicalKeyboardKey.altLeft ||
@@ -376,12 +508,50 @@ class InputModel {
command = false; command = false;
} }
toReleaseKeys.updateKeyUp(key, e); toReleaseRawKeys.updateKeyUp(key, e);
} }
// * Currently mobile does not enable map mode // * Currently mobile does not enable map mode
if ((isDesktop || isWebDesktop) && keyboardMode == 'map') { if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
mapKeyboardMode(e); mapKeyboardModeRaw(e);
} else {
legacyKeyboardModeRaw(e);
}
return KeyEventResult.handled;
}
KeyEventResult handleKeyEvent(KeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (!isInputSourceFlutter) {
if (isDesktop) {
return KeyEventResult.handled;
} else if (isWeb) {
return KeyEventResult.ignored;
}
}
if (isWindows || isLinux) {
// Ignore meta keys. Because flutter window will loose focus if meta key is pressed.
if (e.physicalKey == PhysicalKeyboardKey.metaLeft ||
e.physicalKey == PhysicalKeyboardKey.metaRight) {
return KeyEventResult.handled;
}
}
if (e is KeyUpEvent) {
handleKeyUpEventModifiers(e);
} else if (e is KeyDownEvent) {
handleKeyDownEventModifiers(e);
}
// * Currently mobile does not enable map mode
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
// FIXME: e.character is wrong for dead keys, eg: ^ in de
newKeyboardMode(
e.character ?? '',
e.physicalKey.usbHidUsage & 0xFFFF,
// Show repeat event be converted to "release+press" events?
e is KeyDownEvent || e is KeyRepeatEvent);
} else { } else {
legacyKeyboardMode(e); legacyKeyboardMode(e);
} }
@@ -389,7 +559,33 @@ class InputModel {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
void mapKeyboardMode(RawKeyEvent e) { /// Send Key Event
void newKeyboardMode(String character, int usbHid, bool down) {
const capslock = 1;
const numlock = 2;
const scrolllock = 3;
int lockModes = 0;
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.capsLock)) {
lockModes |= (1 << capslock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.numLock)) {
lockModes |= (1 << numlock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.scrollLock)) {
lockModes |= (1 << scrolllock);
}
bind.sessionHandleFlutterKeyEvent(
sessionId: sessionId,
character: character,
usbHid: usbHid,
lockModes: lockModes,
downOrUp: down);
}
void mapKeyboardModeRaw(RawKeyEvent e) {
int positionCode = -1; int positionCode = -1;
int platformCode = -1; int platformCode = -1;
bool down; bool down;
@@ -441,7 +637,7 @@ class InputModel {
.contains(KeyboardLockMode.scrollLock)) { .contains(KeyboardLockMode.scrollLock)) {
lockModes |= (1 << scrolllock); lockModes |= (1 << scrolllock);
} }
bind.sessionHandleFlutterKeyEvent( bind.sessionHandleFlutterRawKeyEvent(
sessionId: sessionId, sessionId: sessionId,
name: name, name: name,
platformCode: platformCode, platformCode: platformCode,
@@ -450,7 +646,7 @@ class InputModel {
downOrUp: down); downOrUp: down);
} }
void legacyKeyboardMode(RawKeyEvent e) { void legacyKeyboardModeRaw(RawKeyEvent e) {
if (e is RawKeyDownEvent) { if (e is RawKeyDownEvent) {
if (e.repeat) { if (e.repeat) {
sendRawKey(e, press: true); sendRawKey(e, press: true);
@@ -471,6 +667,24 @@ class InputModel {
inputKey(label, down: down, press: press ?? false); inputKey(label, down: down, press: press ?? false);
} }
void legacyKeyboardMode(KeyEvent e) {
if (e is KeyDownEvent) {
sendKey(e, down: true);
} else if (e is KeyRepeatEvent) {
sendKey(e, press: true);
} else if (e is KeyUpEvent) {
sendKey(e);
}
}
void sendKey(KeyEvent e, {bool? down, bool? press}) {
// for maximum compatibility
final label = physicalKeyMap[e.physicalKey.usbHidUsage] ??
logicalKeyMap[e.logicalKey.keyId] ??
e.logicalKey.keyLabel;
inputKey(label, down: down, press: press ?? false);
}
/// Send key stroke event. /// Send key stroke event.
/// [down] indicates the key's state(down or up). /// [down] indicates the key's state(down or up).
/// [press] indicates a click event(down and up). /// [press] indicates a click event(down and up).
@@ -566,7 +780,8 @@ class InputModel {
} }
void enterOrLeave(bool enter) { void enterOrLeave(bool enter) {
toReleaseKeys.release(handleRawKeyEvent); toReleaseKeys.release(handleKeyEvent);
toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false; _pointerMovedAfterEnter = false;
// Fix status // Fix status
@@ -577,6 +792,9 @@ class InputModel {
if (!isInputSourceFlutter) { if (!isInputSourceFlutter) {
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter); bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
} }
if (!isWeb && enter) {
bind.setCurSessionId(sessionId: sessionId);
}
} }
/// Send mouse movement event with distance in [x] and [y]. /// Send mouse movement event with distance in [x] and [y].
@@ -1152,4 +1370,27 @@ class InputModel {
platformFFI.stopDesktopWebListener(); platformFFI.stopDesktopWebListener();
} }
} }
void onMobileBack() => tap(MouseButtons.right);
void onMobileHome() => tap(MouseButtons.wheel);
Future<void> onMobileApps() async {
sendMouse('down', MouseButtons.wheel);
await Future.delayed(const Duration(milliseconds: 500));
sendMouse('up', MouseButtons.wheel);
}
// Simulate a key press event.
// `usbHidUsage` is the USB HID usage code of the key.
Future<void> tapHidKey(int usbHidUsage) async {
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true);
await Future.delayed(Duration(milliseconds: 100));
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false);
}
Future<void> onMobileVolumeUp() async =>
await tapHidKey(PhysicalKeyboardKey.audioVolumeUp.usbHidUsage & 0xFFFF);
Future<void> onMobileVolumeDown() async =>
await tapHidKey(PhysicalKeyboardKey.audioVolumeDown.usbHidUsage & 0xFFFF);
Future<void> onMobilePower() async =>
await tapHidKey(PhysicalKeyboardKey.power.usbHidUsage & 0xFFFF);
} }

View File

@@ -4,15 +4,18 @@ import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:bot_toast/bot_toast.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/peers_view.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/ab_model.dart'; import 'package:flutter_hbb/models/ab_model.dart';
import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/cm_file_model.dart'; import 'package:flutter_hbb/models/cm_file_model.dart';
import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_hbb/models/group_model.dart'; import 'package:flutter_hbb/models/group_model.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/user_model.dart';
@@ -192,10 +195,10 @@ class FfiModel with ChangeNotifier {
_permissions[k] = v == 'true'; _permissions[k] = v == 'true';
}); });
// Only inited at remote page // Only inited at remote page
if (desktopType == DesktopType.remote) { if (parent.target?.connType == ConnType.defaultConn) {
KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false; KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false;
} }
debugPrint('$_permissions'); debugPrint('updatePermission: $_permissions');
notifyListeners(); notifyListeners();
} }
@@ -267,6 +270,8 @@ class FfiModel with ChangeNotifier {
var name = evt['name']; var name = evt['name'];
if (name == 'msgbox') { if (name == 'msgbox') {
handleMsgBox(evt, sessionId, peerId); handleMsgBox(evt, sessionId, peerId);
} else if (name == 'toast') {
handleToast(evt, sessionId, peerId);
} else if (name == 'set_multiple_windows_session') { } else if (name == 'set_multiple_windows_session') {
handleMultipleWindowsSession(evt, sessionId, peerId); handleMultipleWindowsSession(evt, sessionId, peerId);
} else if (name == 'peer_info') { } else if (name == 'peer_info') {
@@ -304,8 +309,13 @@ class FfiModel with ChangeNotifier {
} else if (name == 'job_progress') { } else if (name == 'job_progress') {
parent.target?.fileModel.jobController.tryUpdateJobProgress(evt); parent.target?.fileModel.jobController.tryUpdateJobProgress(evt);
} else if (name == 'job_done') { } else if (name == 'job_done') {
parent.target?.fileModel.jobController.jobDone(evt); bool? refresh =
await parent.target?.fileModel.jobController.jobDone(evt);
if (refresh == true) {
// many job done for delete directory
// todo: refresh may not work when confirm delete local directory
parent.target?.fileModel.refreshAll(); parent.target?.fileModel.refreshAll();
}
} else if (name == 'job_error') { } else if (name == 'job_error') {
parent.target?.fileModel.jobController.jobError(evt); parent.target?.fileModel.jobController.jobError(evt);
} else if (name == 'override_file_confirm') { } else if (name == 'override_file_confirm') {
@@ -365,7 +375,7 @@ class FfiModel with ChangeNotifier {
} else if (name == 'plugin_option') { } else if (name == 'plugin_option') {
handleOption(evt); handleOption(evt);
} else if (name == "sync_peer_hash_password_to_personal_ab") { } else if (name == "sync_peer_hash_password_to_personal_ab") {
if (desktopType == DesktopType.main) { if (desktopType == DesktopType.main || isWeb) {
final id = evt['id']; final id = evt['id'];
final hash = evt['hash']; final hash = evt['hash'];
if (id != null && hash != null) { if (id != null && hash != null) {
@@ -383,8 +393,12 @@ class FfiModel with ChangeNotifier {
handleFollowCurrentDisplay(evt, sessionId, peerId); handleFollowCurrentDisplay(evt, sessionId, peerId);
} else if (name == 'use_texture_render') { } else if (name == 'use_texture_render') {
_handleUseTextureRender(evt, sessionId, peerId); _handleUseTextureRender(evt, sessionId, peerId);
} else if (name == "selected_files") {
if (isWeb) {
parent.target?.fileModel.onSelectedFiles(evt);
}
} else { } else {
debugPrint('Unknown event name: $name'); debugPrint('Event is not handled in the fixed branch: $name');
} }
}; };
} }
@@ -438,20 +452,6 @@ class FfiModel with ChangeNotifier {
_handlePortableServiceRunning(String peerId, Map<String, dynamic> evt) { _handlePortableServiceRunning(String peerId, Map<String, dynamic> evt) {
final running = evt['running'] == 'true'; final running = evt['running'] == 'true';
parent.target?.elevationModel.onPortableServiceRunning(running); parent.target?.elevationModel.onPortableServiceRunning(running);
if (running) {
if (pi.primaryDisplay != kInvalidDisplayIndex) {
if (pi.currentDisplay != pi.primaryDisplay) {
// Notify to switch display
msgBox(sessionId, 'custom-nook-nocancel-hasclose-info', 'Prompt',
'elevated_switch_display_msg', '', parent.target!.dialogManager);
bind.sessionSwitchDisplay(
isDesktop: isDesktop,
sessionId: sessionId,
value: Int32List.fromList([pi.primaryDisplay]),
);
}
}
}
} }
handleAliasChanged(Map<String, dynamic> evt) { handleAliasChanged(Map<String, dynamic> evt) {
@@ -506,10 +506,12 @@ class FfiModel with ChangeNotifier {
newDisplay.width = int.tryParse(evt['width']) ?? newDisplay.width; newDisplay.width = int.tryParse(evt['width']) ?? newDisplay.width;
newDisplay.height = int.tryParse(evt['height']) ?? newDisplay.height; newDisplay.height = int.tryParse(evt['height']) ?? newDisplay.height;
newDisplay.cursorEmbedded = int.tryParse(evt['cursor_embedded']) == 1; newDisplay.cursorEmbedded = int.tryParse(evt['cursor_embedded']) == 1;
newDisplay.originalWidth = newDisplay.originalWidth = int.tryParse(
int.tryParse(evt['original_width']) ?? kInvalidResolutionValue; evt['original_width'] ?? kInvalidResolutionValue.toString()) ??
newDisplay.originalHeight = kInvalidResolutionValue;
int.tryParse(evt['original_height']) ?? kInvalidResolutionValue; newDisplay.originalHeight = int.tryParse(
evt['original_height'] ?? kInvalidResolutionValue.toString()) ??
kInvalidResolutionValue;
newDisplay._scale = _pi.scaleOfDisplay(display); newDisplay._scale = _pi.scaleOfDisplay(display);
_pi.displays[display] = newDisplay; _pi.displays[display] = newDisplay;
@@ -596,6 +598,37 @@ class FfiModel with ChangeNotifier {
} }
} }
handleToast(Map<String, dynamic> evt, SessionID sessionId, String peerId) {
final type = evt['type'] ?? 'info';
final text = evt['text'] ?? '';
final durMsc = evt['dur_msec'] ?? 2000;
final duration = Duration(milliseconds: durMsc);
if ((text).isEmpty) {
BotToast.showLoading(
duration: duration,
clickClose: true,
allowClick: true,
);
} else {
if (type.contains('error')) {
BotToast.showText(
contentColor: Colors.red,
text: translate(text),
duration: duration,
clickClose: true,
onlyOne: true,
);
} else {
BotToast.showText(
text: translate(text),
duration: duration,
clickClose: true,
onlyOne: true,
);
}
}
}
/// Show a message box with [type], [title] and [text]. /// Show a message box with [type], [title] and [text].
showMsgBox(SessionID sessionId, String type, String title, String text, showMsgBox(SessionID sessionId, String type, String title, String text,
String link, bool hasRetry, OverlayDialogManager dialogManager, String link, bool hasRetry, OverlayDialogManager dialogManager,
@@ -737,6 +770,8 @@ class FfiModel with ChangeNotifier {
/// Handle the peer info event based on [evt]. /// Handle the peer info event based on [evt].
handlePeerInfo(Map<String, dynamic> evt, String peerId, bool isCache) async { handlePeerInfo(Map<String, dynamic> evt, String peerId, bool isCache) async {
parent.target?.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
// This call is to ensuer the keyboard mode is updated depending on the peer version. // This call is to ensuer the keyboard mode is updated depending on the peer version.
parent.target?.inputModel.updateKeyboardMode(); parent.target?.inputModel.updateKeyboardMode();
@@ -800,7 +835,7 @@ class FfiModel with ChangeNotifier {
isRefreshing = false; isRefreshing = false;
} }
Map<String, dynamic> features = json.decode(evt['features']); Map<String, dynamic> features = json.decode(evt['features']);
_pi.features.privacyMode = features['privacy_mode'] == 1; _pi.features.privacyMode = features['privacy_mode'] == true;
if (!isCache) { if (!isCache) {
handleResolutions(peerId, evt["resolutions"]); handleResolutions(peerId, evt["resolutions"]);
} }
@@ -844,7 +879,7 @@ class FfiModel with ChangeNotifier {
for (final mode in [kKeyMapMode, kKeyLegacyMode]) { for (final mode in [kKeyMapMode, kKeyLegacyMode]) {
if (bind.sessionIsKeyboardModeSupported( if (bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: mode)) { sessionId: sessionId, mode: mode)) {
bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode); await bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
break; break;
} }
} }
@@ -917,10 +952,12 @@ class FfiModel with ChangeNotifier {
if (parent.target?.connType == ConnType.defaultConn && if (parent.target?.connType == ConnType.defaultConn &&
parent.target != null && parent.target != null &&
parent.target!.ffiModel.permissions['keyboard'] != false) { parent.target!.ffiModel.permissions['keyboard'] != false) {
Timer( Timer(Duration(milliseconds: delayMSecs), () {
Duration(milliseconds: delayMSecs), if (parent.target!.dialogManager.mobileActionsOverlayVisible.isTrue) {
() => parent.target!.dialogManager parent.target!.dialogManager
.showMobileActionsOverlay(ffi: parent.target!)); .showMobileActionsOverlay(ffi: parent.target!);
}
});
} }
} }
} }
@@ -973,7 +1010,9 @@ class FfiModel with ChangeNotifier {
} }
updateLastCursorId(Map<String, dynamic> evt) { updateLastCursorId(Map<String, dynamic> evt) {
parent.target?.cursorModel.id = int.parse(evt['id']); // int.parse(evt['id']) may cause FormatException
// Unhandled Exception: FormatException: Positive input exceeds the limit of integer 18446744071749110741
parent.target?.cursorModel.id = evt['id'];
} }
handleCursorId(Map<String, dynamic> evt) { handleCursorId(Map<String, dynamic> evt) {
@@ -1011,14 +1050,15 @@ class FfiModel with ChangeNotifier {
// Notify to switch display // Notify to switch display
msgBox(sessionId, 'custom-nook-nocancel-hasclose-info', 'Prompt', msgBox(sessionId, 'custom-nook-nocancel-hasclose-info', 'Prompt',
'display_is_plugged_out_msg', '', parent.target!.dialogManager); 'display_is_plugged_out_msg', '', parent.target!.dialogManager);
final newDisplay = pi.primaryDisplay == kInvalidDisplayIndex final isPeerPrimaryDisplayValid =
? 0 pi.primaryDisplay == kInvalidDisplayIndex ||
: pi.primaryDisplay; pi.primaryDisplay >= pi.displays.length;
final displays = newDisplay; final newDisplay =
isPeerPrimaryDisplayValid ? 0 : pi.primaryDisplay;
bind.sessionSwitchDisplay( bind.sessionSwitchDisplay(
isDesktop: isDesktop, isDesktop: isDesktop,
sessionId: sessionId, sessionId: sessionId,
value: Int32List.fromList([displays]), value: Int32List.fromList([newDisplay]),
); );
if (_pi.isSupportMultiUiSession) { if (_pi.isSupportMultiUiSession) {
@@ -1183,25 +1223,49 @@ class ImageModel with ChangeNotifier {
addCallbackOnFirstImage(Function(String) cb) => callbacksOnFirstImage.add(cb); addCallbackOnFirstImage(Function(String) cb) => callbacksOnFirstImage.add(cb);
onRgba(int display, Uint8List rgba) { clearImage() => _image = null;
final pid = parent.target?.id;
img.decodeImageFromPixels( bool _webDecodingRgba = false;
rgba, final List<Uint8List> _webRgbaList = List.empty(growable: true);
parent.target?.ffiModel.rect?.width.toInt() ?? 0, webOnRgba(int display, Uint8List rgba) async {
parent.target?.ffiModel.rect?.height.toInt() ?? 0, // deep copy needed, otherwise "instantiateCodec failed: TypeError: Cannot perform Construct on a detached ArrayBuffer"
isWeb ? ui.PixelFormat.rgba8888 : ui.PixelFormat.bgra8888, _webRgbaList.add(Uint8List.fromList(rgba));
onPixelsCopied: () { if (_webDecodingRgba) {
// Unlock the rgba memory from rust codes. return;
platformFFI.nextRgba(sessionId, display);
}).then((image) {
if (parent.target?.id != pid) return;
try {
// my throw exception, because the listener maybe already dispose
update(image);
} catch (e) {
debugPrint('update image: $e');
} }
}); _webDecodingRgba = true;
try {
while (_webRgbaList.isNotEmpty) {
final rgba2 = _webRgbaList.last;
_webRgbaList.clear();
await decodeAndUpdate(display, rgba2);
}
} catch (e) {
debugPrint('onRgba error: $e');
}
_webDecodingRgba = false;
}
onRgba(int display, Uint8List rgba) async {
try {
await decodeAndUpdate(display, rgba);
} catch (e) {
debugPrint('onRgba error: $e');
}
platformFFI.nextRgba(sessionId, display);
}
decodeAndUpdate(int display, Uint8List rgba) async {
final pid = parent.target?.id;
final rect = parent.target?.ffiModel.pi.getDisplayRect(display);
final image = await img.decodeImageFromPixels(
rgba,
rect?.width.toInt() ?? 0,
rect?.height.toInt() ?? 0,
isWeb ? ui.PixelFormat.rgba8888 : ui.PixelFormat.bgra8888,
);
if (parent.target?.id != pid) return;
await update(image);
} }
update(ui.Image? image) async { update(ui.Image? image) async {
@@ -1573,22 +1637,24 @@ class CanvasModel with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
updateScale(double v) { updateScale(double v, Offset focalPoint) {
if (parent.target?.imageModel.image == null) return; if (parent.target?.imageModel.image == null) return;
final offset = parent.target?.cursorModel.offset ?? const Offset(0, 0); final s = _scale;
var r = parent.target?.cursorModel.getVisibleRect() ?? Rect.zero;
final px0 = (offset.dx - r.left) * _scale;
final py0 = (offset.dy - r.top) * _scale;
_scale *= v; _scale *= v;
final maxs = parent.target?.imageModel.maxScale ?? 1; final maxs = parent.target?.imageModel.maxScale ?? 1;
final mins = parent.target?.imageModel.minScale ?? 1; final mins = parent.target?.imageModel.minScale ?? 1;
if (_scale > maxs) _scale = maxs; if (_scale > maxs) _scale = maxs;
if (_scale < mins) _scale = mins; if (_scale < mins) _scale = mins;
r = parent.target?.cursorModel.getVisibleRect() ?? Rect.zero; // (focalPoint.dx - _x_1) / s1 + displayOriginX = (focalPoint.dx - _x_2) / s2 + displayOriginX
final px1 = (offset.dx - r.left) * _scale; // _x_2 = focalPoint.dx - (focalPoint.dx - _x_1) / s1 * s2
final py1 = (offset.dy - r.top) * _scale; _x = focalPoint.dx - (focalPoint.dx - _x) / s * _scale;
_x -= px1 - px0; final adjustForKeyboard =
_y -= py1 - py0; parent.target?.cursorModel.adjustForKeyboard() ?? 0.0;
// (focalPoint.dy - _y_1 + adjust) / s1 + displayOriginY = (focalPoint.dy - _y_2 + adjust) / s2 + displayOriginY
// _y_2 = focalPoint.dy + adjust - (focalPoint.dy - _y_1 + adjust) / s1 * s2
_y = focalPoint.dy +
adjustForKeyboard -
(focalPoint.dy - _y + adjustForKeyboard) / s * _scale;
notifyListeners(); notifyListeners();
} }
@@ -1619,7 +1685,7 @@ class CanvasModel with ChangeNotifier {
// data for cursor // data for cursor
class CursorData { class CursorData {
final String peerId; final String peerId;
final int id; final String id;
final img2.Image image; final img2.Image image;
double scale; double scale;
Uint8List? data; Uint8List? data;
@@ -1699,12 +1765,12 @@ const _forbiddenCursorPng =
const _defaultCursorPng = const _defaultCursorPng =
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARzQklUCAgICHwIZIgAAAFmSURBVFiF7dWxSlxREMbx34QFDRowYBchZSxSCWlMCOwD5FGEFHap06UI7KPsAyyEEIQFqxRaCqYTsqCJFsKkuAeRXb17wrqV918dztw55zszc2fo6Oh47MR/e3zO1/iAHWmznHKGQwx9ip/LEbCfazbsoY8j/JLOhcC6sCW9wsjEwJf483AC9nPNc1+lFRwI13d+l3rYFS799rFGxJMqARv2pBXh+72XQ7gWvklPS7TmMl9Ak/M+DqrENvxAv/guKKApuKPWl0/TROK4+LbSqzhuB+OZ3fRSeFPWY+Fkyn56Y29hfgTSpnQ+s98cvorVey66uPlNFxKwZOYLCGfCs5n9NMYVrsp6mvXSoFqpqYFDvMBkStgJJe93dZOwVXxbqUnBENulydSReqUrDhcX0PT2EXarBYS3GNXMhboinBgIl9K71kg0L3+PvyYGdVpruT2MwrF0iotiXfIwus0Dj+OOjo6Of+e7ab74RkpgAAAAAElFTkSuQmCC'; 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARzQklUCAgICHwIZIgAAAFmSURBVFiF7dWxSlxREMbx34QFDRowYBchZSxSCWlMCOwD5FGEFHap06UI7KPsAyyEEIQFqxRaCqYTsqCJFsKkuAeRXb17wrqV918dztw55zszc2fo6Oh47MR/e3zO1/iAHWmznHKGQwx9ip/LEbCfazbsoY8j/JLOhcC6sCW9wsjEwJf483AC9nPNc1+lFRwI13d+l3rYFS799rFGxJMqARv2pBXh+72XQ7gWvklPS7TmMl9Ak/M+DqrENvxAv/guKKApuKPWl0/TROK4+LbSqzhuB+OZ3fRSeFPWY+Fkyn56Y29hfgTSpnQ+s98cvorVey66uPlNFxKwZOYLCGfCs5n9NMYVrsp6mvXSoFqpqYFDvMBkStgJJe93dZOwVXxbqUnBENulydSReqUrDhcX0PT2EXarBYS3GNXMhboinBgIl9K71kg0L3+PvyYGdVpruT2MwrF0iotiXfIwus0Dj+OOjo6Of+e7ab74RkpgAAAAAElFTkSuQmCC';
const kPreForbiddenCursorId = -2; const kPreForbiddenCursorId = "-2";
final preForbiddenCursor = PredefinedCursor( final preForbiddenCursor = PredefinedCursor(
png: _forbiddenCursorPng, png: _forbiddenCursorPng,
id: kPreForbiddenCursorId, id: kPreForbiddenCursorId,
); );
const kPreDefaultCursorId = -1; const kPreDefaultCursorId = "-1";
final preDefaultCursor = PredefinedCursor( final preDefaultCursor = PredefinedCursor(
png: _defaultCursorPng, png: _defaultCursorPng,
id: kPreDefaultCursorId, id: kPreDefaultCursorId,
@@ -1717,7 +1783,7 @@ class PredefinedCursor {
img2.Image? _image2; img2.Image? _image2;
CursorData? _cache; CursorData? _cache;
String png; String png;
int id; String id;
double Function(double)? hotxGetter; double Function(double)? hotxGetter;
double Function(double)? hotyGetter; double Function(double)? hotyGetter;
@@ -1733,7 +1799,7 @@ class PredefinedCursor {
_image2 = img2.decodePng(base64Decode(png)); _image2 = img2.decodePng(base64Decode(png));
if (_image2 != null) { if (_image2 != null) {
// The png type of forbidden cursor image is `PngColorType.indexed`. // The png type of forbidden cursor image is `PngColorType.indexed`.
if (isWindows && id == kPreForbiddenCursorId) { if (id == kPreForbiddenCursorId) {
_image2 = _image2!.convert(format: img2.Format.uint8, numChannels: 4); _image2 = _image2!.convert(format: img2.Format.uint8, numChannels: 4);
} }
@@ -1775,13 +1841,15 @@ class PredefinedCursor {
class CursorModel with ChangeNotifier { class CursorModel with ChangeNotifier {
ui.Image? _image; ui.Image? _image;
final _images = <int, Tuple3<ui.Image, double, double>>{}; final _images = <String, Tuple3<ui.Image, double, double>>{};
CursorData? _cache; CursorData? _cache;
final _cacheMap = <int, CursorData>{}; final _cacheMap = <String, CursorData>{};
final _cacheKeys = <String>{}; final _cacheKeys = <String>{};
double _x = -10000; double _x = -10000;
double _y = -10000; double _y = -10000;
int _id = -1; // int.parse(evt['id']) may cause FormatException
// So we use String here.
String _id = "-1";
double _hotx = 0; double _hotx = 0;
double _hoty = 0; double _hoty = 0;
double _displayOriginX = 0; double _displayOriginX = 0;
@@ -1795,6 +1863,33 @@ class CursorModel with ChangeNotifier {
String peerId = ''; String peerId = '';
WeakReference<FFI> parent; WeakReference<FFI> parent;
// Only for mobile, touch mode
// To block touch event above the KeyHelpTools
//
// A better way is to not listen events from the KeyHelpTools.
// But we're now using a Container(child: Stack(...)) to wrap the KeyHelpTools,
// and the listener is on the Container.
Rect? _keyHelpToolsRect;
// `lastIsBlocked` is only used in common/widgets/remote_input.dart -> _RawTouchGestureDetectorRegionState -> onDoubleTap()
// Because onDoubleTap() doesn't have the `event` parameter, we can't get the touch event's position.
bool _lastIsBlocked = false;
double _yForKeyboardAdjust = 0;
keyHelpToolsVisibilityChanged(Rect? r) {
_keyHelpToolsRect = r;
if (r == null) {
_lastIsBlocked = false;
} else {
// Block the touch event is safe here.
// `lastIsBlocked` is only used in onDoubleTap() to block the touch event from the KeyHelpTools.
// `lastIsBlocked` will be set when the cursor is moving or touch somewhere else.
_lastIsBlocked = true;
}
_yForKeyboardAdjust = _y;
}
get lastIsBlocked => _lastIsBlocked;
ui.Image? get image => _image; ui.Image? get image => _image;
CursorData? get cache => _cache; CursorData? get cache => _cache;
@@ -1808,7 +1903,7 @@ class CursorModel with ChangeNotifier {
double get hotx => _hotx; double get hotx => _hotx;
double get hoty => _hoty; double get hoty => _hoty;
set id(int id) => _id = id; set id(String id) => _id = id;
bool get isPeerControlProtected => bool get isPeerControlProtected =>
DateTime.now().difference(_lastPeerMouse).inMilliseconds < DateTime.now().difference(_lastPeerMouse).inMilliseconds <
@@ -1839,28 +1934,52 @@ class CursorModel with ChangeNotifier {
return Rect.fromLTWH(x0, y0, size.width / scale, size.height / scale); return Rect.fromLTWH(x0, y0, size.width / scale, size.height / scale);
} }
get keyboardHeight => MediaQueryData.fromWindow(ui.window).viewInsets.bottom;
get scale => parent.target?.canvasModel.scale ?? 1.0;
double adjustForKeyboard() { double adjustForKeyboard() {
if (keyboardHeight < 100) {
return 0.0;
}
final m = MediaQueryData.fromWindow(ui.window); final m = MediaQueryData.fromWindow(ui.window);
var keyboardHeight = m.viewInsets.bottom;
final size = m.size; final size = m.size;
if (keyboardHeight < 100) return 0;
final s = parent.target?.canvasModel.scale ?? 1.0;
final thresh = (size.height - keyboardHeight) / 2; final thresh = (size.height - keyboardHeight) / 2;
var h = (_y - getVisibleRect().top) * s; // local physical display height final h = (_yForKeyboardAdjust - getVisibleRect().top) *
scale; // local physical display height
return h - thresh; return h - thresh;
} }
move(double x, double y) { // mobile Soft keyboard, block touch event from the KeyHelpTools
moveLocal(x, y); shouldBlock(double x, double y) {
parent.target?.inputModel.moveMouse(_x, _y); if (!(parent.target?.ffiModel.touchMode ?? false)) {
return false;
}
if (_keyHelpToolsRect == null) {
return false;
}
if (isPointInRect(Offset(x, y), _keyHelpToolsRect!)) {
return true;
}
return false;
} }
moveLocal(double x, double y) { move(double x, double y) {
final scale = parent.target?.canvasModel.scale ?? 1.0; if (shouldBlock(x, y)) {
_lastIsBlocked = true;
return false;
}
_lastIsBlocked = false;
moveLocal(x, y, adjust: adjustForKeyboard());
parent.target?.inputModel.moveMouse(_x, _y);
return true;
}
moveLocal(double x, double y, {double adjust = 0}) {
final xoffset = parent.target?.canvasModel.x ?? 0; final xoffset = parent.target?.canvasModel.x ?? 0;
final yoffset = parent.target?.canvasModel.y ?? 0; final yoffset = parent.target?.canvasModel.y ?? 0;
_x = (x - xoffset) / scale + _displayOriginX; _x = (x - xoffset) / scale + _displayOriginX;
_y = (y - yoffset) / scale + _displayOriginY; _y = (y - yoffset + adjust) / scale + _displayOriginY;
notifyListeners(); notifyListeners();
} }
@@ -1986,7 +2105,7 @@ class CursorModel with ChangeNotifier {
} }
updateCursorData(Map<String, dynamic> evt) async { updateCursorData(Map<String, dynamic> evt) async {
final id = int.parse(evt['id']); final id = evt['id'];
final hotx = double.parse(evt['hotx']); final hotx = double.parse(evt['hotx']);
final hoty = double.parse(evt['hoty']); final hoty = double.parse(evt['hoty']);
final width = int.parse(evt['width']); final width = int.parse(evt['width']);
@@ -2011,7 +2130,7 @@ class CursorModel with ChangeNotifier {
Future<bool> _updateCache( Future<bool> _updateCache(
Uint8List rgba, Uint8List rgba,
ui.Image image, ui.Image image,
int id, String id,
double hotx, double hotx,
double hoty, double hoty,
int w, int w,
@@ -2127,6 +2246,7 @@ class CursorModel with ChangeNotifier {
debugPrint("deleting cursor with key $k"); debugPrint("deleting cursor with key $k");
deleteCustomCursor(k); deleteCustomCursor(k);
} }
resetSystemCursor();
} }
trySetRemoteWindowCoords() { trySetRemoteWindowCoords() {
@@ -2173,8 +2293,10 @@ class QualityMonitorModel with ChangeNotifier {
updateQualityStatus(Map<String, dynamic> evt) { updateQualityStatus(Map<String, dynamic> evt) {
try { try {
if ((evt['speed'] as String).isNotEmpty) _data.speed = evt['speed']; if (evt.containsKey('speed') && (evt['speed'] as String).isNotEmpty) {
if ((evt['fps'] as String).isNotEmpty) { _data.speed = evt['speed'];
}
if (evt.containsKey('fps') && (evt['fps'] as String).isNotEmpty) {
final fps = jsonDecode(evt['fps']) as Map<String, dynamic>; final fps = jsonDecode(evt['fps']) as Map<String, dynamic>;
final pi = parent.target?.ffiModel.pi; final pi = parent.target?.ffiModel.pi;
if (pi != null) { if (pi != null) {
@@ -2195,14 +2317,18 @@ class QualityMonitorModel with ChangeNotifier {
_data.fps = null; _data.fps = null;
} }
} }
if ((evt['delay'] as String).isNotEmpty) _data.delay = evt['delay']; if (evt.containsKey('delay') && (evt['delay'] as String).isNotEmpty) {
if ((evt['target_bitrate'] as String).isNotEmpty) { _data.delay = evt['delay'];
}
if (evt.containsKey('target_bitrate') &&
(evt['target_bitrate'] as String).isNotEmpty) {
_data.targetBitrate = evt['target_bitrate']; _data.targetBitrate = evt['target_bitrate'];
} }
if ((evt['codec_format'] as String).isNotEmpty) { if (evt.containsKey('codec_format') &&
(evt['codec_format'] as String).isNotEmpty) {
_data.codecFormat = evt['codec_format']; _data.codecFormat = evt['codec_format'];
} }
if ((evt['chroma'] as String).isNotEmpty) { if (evt.containsKey('chroma') && (evt['chroma'] as String).isNotEmpty) {
_data.chroma = evt['chroma']; _data.chroma = evt['chroma'];
} }
notifyListeners(); notifyListeners();
@@ -2332,6 +2458,9 @@ class FFI {
late final ElevationModel elevationModel; // session late final ElevationModel elevationModel; // session
late final CmFileModel cmFileModel; // cm late final CmFileModel cmFileModel; // cm
late final TextureModel textureModel; //session late final TextureModel textureModel; //session
late final Peers recentPeersModel; // global
late final Peers favoritePeersModel; // global
late final Peers lanPeersModel; // global
FFI(SessionID? sId) { FFI(SessionID? sId) {
sessionId = sId ?? (isDesktop ? Uuid().v4obj() : _constSessionId); sessionId = sId ?? (isDesktop ? Uuid().v4obj() : _constSessionId);
@@ -2352,6 +2481,16 @@ class FFI {
elevationModel = ElevationModel(WeakReference(this)); elevationModel = ElevationModel(WeakReference(this));
cmFileModel = CmFileModel(WeakReference(this)); cmFileModel = CmFileModel(WeakReference(this));
textureModel = TextureModel(WeakReference(this)); textureModel = TextureModel(WeakReference(this));
recentPeersModel = Peers(
name: PeersModelName.recent,
loadEvent: LoadEvent.recent,
getInitPeers: null);
favoritePeersModel = Peers(
name: PeersModelName.favorite,
loadEvent: LoadEvent.favorite,
getInitPeers: null);
lanPeersModel = Peers(
name: PeersModelName.lan, loadEvent: LoadEvent.lan, getInitPeers: null);
} }
/// Mobile reuse FFI /// Mobile reuse FFI
@@ -2393,9 +2532,10 @@ class FFI {
cursorModel.peerId = id; cursorModel.peerId = id;
} }
final isNewPeer = tabWindowId == null;
// If tabWindowId != null, this session is a "tab -> window" one. // If tabWindowId != null, this session is a "tab -> window" one.
// Else this session is a new one. // Else this session is a new one.
if (tabWindowId == null) { if (isNewPeer) {
// ignore: unused_local_variable // ignore: unused_local_variable
final addRes = bind.sessionAddSync( final addRes = bind.sessionAddSync(
sessionId: sessionId, sessionId: sessionId,
@@ -2414,38 +2554,43 @@ class FFI {
'Unreachable, failed to add existed session to $id, the displays is null while display is $display'); 'Unreachable, failed to add existed session to $id, the displays is null while display is $display');
return; return;
} }
final addRes = bind.sessionAddExistedSync(id: id, sessionId: sessionId); final addRes = bind.sessionAddExistedSync(
id: id, sessionId: sessionId, displays: Int32List.fromList(displays));
if (addRes != '') { if (addRes != '') {
debugPrint( debugPrint(
'Unreachable, failed to add existed session to $id, $addRes'); 'Unreachable, failed to add existed session to $id, $addRes');
return; return;
} }
bind.sessionTryAddDisplay(
sessionId: sessionId, displays: Int32List.fromList(displays));
ffiModel.pi.currentDisplay = display; ffiModel.pi.currentDisplay = display;
} }
if (isDesktop && connType == ConnType.defaultConn) { if (isDesktop && connType == ConnType.defaultConn) {
textureModel.updateCurrentDisplay(display ?? 0); textureModel.updateCurrentDisplay(display ?? 0);
} }
final stream = bind.sessionStart(sessionId: sessionId, id: id);
// CAUTION: `sessionStart()` and `sessionStartWithDisplays()` are an async functions.
// Though the stream is returned immediately, the stream may not be ready.
// Any operations that depend on the stream should be carefully handled.
late final Stream<EventToUI> stream;
if (isNewPeer || display == null || displays == null) {
stream = bind.sessionStart(sessionId: sessionId, id: id);
} else {
// We have to put displays in `sessionStart()` to make sure the stream is ready
// and then the displays' capturing requests can be sent.
stream = bind.sessionStartWithDisplays(
sessionId: sessionId, id: id, displays: Int32List.fromList(displays));
}
if (isWeb) { if (isWeb) {
platformFFI.setRgbaCallback((int display, Uint8List data) { platformFFI.setRgbaCallback((int display, Uint8List data) {
onEvent2UIRgba(); onEvent2UIRgba();
imageModel.onRgba(display, data); imageModel.onRgba(display, data);
}); });
this.id = id;
return; return;
} }
final cb = ffiModel.startEventListener(sessionId, id); final cb = ffiModel.startEventListener(sessionId, id);
// Force refresh displays.
// The controlled side may not refresh the image when the (peer,display) is already subscribed.
if (displays != null) {
for (final display in displays) {
bind.sessionRefresh(sessionId: sessionId, display: display);
}
}
imageModel.updateUserTextureRender(); imageModel.updateUserTextureRender();
final hasGpuTextureRender = bind.mainHasGpuTextureRender(); final hasGpuTextureRender = bind.mainHasGpuTextureRender();
final SimpleWrapper<bool> isToNewWindowNotified = SimpleWrapper(false); final SimpleWrapper<bool> isToNewWindowNotified = SimpleWrapper(false);
@@ -2496,29 +2641,30 @@ class FFI {
} }
} else if (message is EventToUI_Rgba) { } else if (message is EventToUI_Rgba) {
final display = message.field0; final display = message.field0;
if (imageModel.useTextureRender) {
debugPrint("EventToUI_Rgba display:$display");
textureModel.setTextureType(display: display, gpuTexture: false);
onEvent2UIRgba();
} else {
// Fetch the image buffer from rust codes. // Fetch the image buffer from rust codes.
final sz = platformFFI.getRgbaSize(sessionId, display); final sz = platformFFI.getRgbaSize(sessionId, display);
if (sz == 0) { if (sz == 0) {
platformFFI.nextRgba(sessionId, display);
return; return;
} }
final rgba = platformFFI.getRgba(sessionId, display, sz); final rgba = platformFFI.getRgba(sessionId, display, sz);
if (rgba != null) { if (rgba != null) {
onEvent2UIRgba(); onEvent2UIRgba();
imageModel.onRgba(display, rgba); await imageModel.onRgba(display, rgba);
} } else {
platformFFI.nextRgba(sessionId, display);
} }
} else if (message is EventToUI_Texture) { } else if (message is EventToUI_Texture) {
final display = message.field0; final display = message.field0;
debugPrint("EventToUI_Texture display:$display"); final gpuTexture = message.field1;
if (hasGpuTextureRender) { debugPrint(
textureModel.setTextureType(display: display, gpuTexture: true); "EventToUI_Texture display:$display, gpuTexture:$gpuTexture");
onEvent2UIRgba(); if (gpuTexture && !hasGpuTextureRender) {
debugPrint('the gpuTexture is not supported.');
return;
} }
textureModel.setTextureType(display: display, gpuTexture: gpuTexture);
onEvent2UIRgba();
} }
}(); }();
}); });
@@ -2554,8 +2700,9 @@ class FFI {
remember: remember); remember: remember);
} }
void send2FA(SessionID sessionId, String code) { void send2FA(SessionID sessionId, String code, bool trustThisDevice) {
bind.sessionSend2Fa(sessionId: sessionId, code: code); bind.sessionSend2Fa(
sessionId: sessionId, code: code, trustThisDevice: trustThisDevice);
} }
/// Close the remote session. /// Close the remote session.
@@ -2572,7 +2719,7 @@ class FFI {
canvasModel.scale, canvasModel.scale,
ffiModel.pi.currentDisplay); ffiModel.pi.currentDisplay);
} }
imageModel.update(null); await imageModel.update(null);
cursorModel.clear(); cursorModel.clear();
ffiModel.clear(); ffiModel.clear();
canvasModel.clear(); canvasModel.clear();
@@ -2685,6 +2832,7 @@ class PeerInfo with ChangeNotifier {
bool get isSupportMultiDisplay => bool get isSupportMultiDisplay =>
(isDesktop || isWebDesktop) && isSupportMultiUiSession; (isDesktop || isWebDesktop) && isSupportMultiUiSession;
bool get forceTextureRender => currentDisplay == kAllDisplayValue;
bool get cursorEmbedded => tryGetDisplay()?.cursorEmbedded ?? false; bool get cursorEmbedded => tryGetDisplay()?.cursorEmbedded ?? false;
@@ -2693,30 +2841,32 @@ class PeerInfo with ChangeNotifier {
bool get isAmyuniIdd => bool get isAmyuniIdd =>
platformAdditions[kPlatformAdditionsIddImpl] == 'amyuni_idd'; platformAdditions[kPlatformAdditionsIddImpl] == 'amyuni_idd';
Display? tryGetDisplay() { Display? tryGetDisplay({int? display}) {
if (displays.isEmpty) { if (displays.isEmpty) {
return null; return null;
} }
if (currentDisplay == kAllDisplayValue) { display ??= currentDisplay;
if (display == kAllDisplayValue) {
return displays[0]; return displays[0];
} else { } else {
if (currentDisplay > 0 && currentDisplay < displays.length) { if (display > 0 && display < displays.length) {
return displays[currentDisplay]; return displays[display];
} else { } else {
return displays[0]; return displays[0];
} }
} }
} }
Display? tryGetDisplayIfNotAllDisplay() { Display? tryGetDisplayIfNotAllDisplay({int? display}) {
if (displays.isEmpty) { if (displays.isEmpty) {
return null; return null;
} }
if (currentDisplay == kAllDisplayValue) { display ??= currentDisplay;
if (display == kAllDisplayValue) {
return null; return null;
} }
if (currentDisplay >= 0 && currentDisplay < displays.length) { if (display >= 0 && display < displays.length) {
return displays[currentDisplay]; return displays[display];
} else { } else {
return null; return null;
} }
@@ -2740,6 +2890,12 @@ class PeerInfo with ChangeNotifier {
} }
return 1.0; return 1.0;
} }
Rect? getDisplayRect(int display) {
final d = tryGetDisplayIfNotAllDisplay(display: display);
if (d == null) return null;
return Rect.fromLTWH(d.x, d.y, d.width.toDouble(), d.height.toDouble());
}
} }
const canvasKey = 'canvas'; const canvasKey = 'canvas';

View File

@@ -48,6 +48,12 @@ class PlatformFFI {
static get isMain => instance._appType == kAppTypeMain; static get isMain => instance._appType == kAppTypeMain;
static String getByName(String name, [String arg = '']) {
return '';
}
static void setByName(String name, [String value = '']) {}
static Future<String> getVersion() async { static Future<String> getVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform(); PackageInfo packageInfo = await PackageInfo.fromPlatform();
return packageInfo.version; return packageInfo.version;
@@ -117,9 +123,13 @@ class PlatformFFI {
? DynamicLibrary.open('librustdesk.so') ? DynamicLibrary.open('librustdesk.so')
: isWindows : isWindows
? DynamicLibrary.open('librustdesk.dll') ? DynamicLibrary.open('librustdesk.dll')
: isMacOS :
? DynamicLibrary.open("liblibrustdesk.dylib") // Use executable itself as the dynamic library for MacOS.
: DynamicLibrary.process(); // Multiple dylib instances will cause some global instances to be invalid.
// eg. `lazy_static` objects in rust side, will be created more than once, which is not expected.
//
// isMacOS? DynamicLibrary.open("liblibrustdesk.dylib") :
DynamicLibrary.process();
debugPrint('initializing FFI $_appType'); debugPrint('initializing FFI $_appType');
try { try {
_session_get_rgba = dylib.lookupFunction<F3Dart, F3>("session_get_rgba"); _session_get_rgba = dylib.lookupFunction<F3Dart, F3>("session_get_rgba");
@@ -132,9 +142,10 @@ class PlatformFFI {
_ffiBind = RustdeskImpl(dylib); _ffiBind = RustdeskImpl(dylib);
if (isLinux) { if (isLinux) {
// Start a dbus service, no need to await if (isMain) {
// Start a dbus service for uri links, no need to await
_ffiBind.mainStartDbusServer(); _ffiBind.mainStartDbusServer();
_ffiBind.mainStartPa(); }
} else if (isMacOS && isMain) { } else if (isMacOS && isMain) {
// Start ipc service for uri links. // Start ipc service for uri links.
_ffiBind.mainStartIpcUrlServer(); _ffiBind.mainStartIpcUrlServer();
@@ -271,4 +282,6 @@ class PlatformFFI {
void syncAndroidServiceAppDirConfigPath() { void syncAndroidServiceAppDirConfigPath() {
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir); invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
} }
void setFullscreenCallback(void Function(bool) fun) {}
} }

View File

@@ -194,25 +194,34 @@ class Peers extends ChangeNotifier {
} }
void _updateOnlineState(Map<String, dynamic> evt) { void _updateOnlineState(Map<String, dynamic> evt) {
int changedCount = 0;
evt['onlines'].split(',').forEach((online) { evt['onlines'].split(',').forEach((online) {
for (var i = 0; i < peers.length; i++) { for (var i = 0; i < peers.length; i++) {
if (peers[i].id == online) { if (peers[i].id == online) {
if (!peers[i].online) {
changedCount += 1;
peers[i].online = true; peers[i].online = true;
} }
} }
}
}); });
evt['offlines'].split(',').forEach((offline) { evt['offlines'].split(',').forEach((offline) {
for (var i = 0; i < peers.length; i++) { for (var i = 0; i < peers.length; i++) {
if (peers[i].id == offline) { if (peers[i].id == offline) {
if (peers[i].online) {
changedCount += 1;
peers[i].online = false; peers[i].online = false;
} }
} }
}
}); });
if (changedCount > 0) {
event = UpdateEvent.online; event = UpdateEvent.online;
notifyListeners(); notifyListeners();
} }
}
void _updatePeers(Map<String, dynamic> evt) { void _updatePeers(Map<String, dynamic> evt) {
final onlineStates = _getOnlineStates(); final onlineStates = _getOnlineStates();

View File

@@ -152,7 +152,7 @@ class PeerTabModel with ChangeNotifier {
// https://github.com/flutter/flutter/issues/101275#issuecomment-1604541700 // https://github.com/flutter/flutter/issues/101275#issuecomment-1604541700
// After onTap, the shift key should be pressed for a while when not in multiselection mode, // After onTap, the shift key should be pressed for a while when not in multiselection mode,
// because onTap is delayed when onDoubleTap is not null // because onTap is delayed when onDoubleTap is not null
if (isDesktop && !_isShiftDown) return; if (isDesktop || isWebDesktop) return;
_multiSelectionMode = true; _multiSelectionMode = true;
} }
final cached = _currentTabCachedPeers.map((e) => e.id).toList(); final cached = _currentTabCachedPeers.map((e) => e.id).toList();
@@ -184,10 +184,17 @@ class PeerTabModel with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// `notifyListeners()` will cause many rebuilds.
// So, we need to reduce the calls to "notifyListeners()" only when necessary.
// A better way is to use a new model.
setCurrentTabCachedPeers(List<Peer> peers) { setCurrentTabCachedPeers(List<Peer> peers) {
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
final isPreEmpty = _currentTabCachedPeers.isEmpty;
_currentTabCachedPeers = peers; _currentTabCachedPeers = peers;
final isNowEmpty = _currentTabCachedPeers.isEmpty;
if (isPreEmpty != isNowEmpty) {
notifyListeners(); notifyListeners();
}
}); });
} }

View File

@@ -6,3 +6,11 @@ final platformFFI = PlatformFFI.instance;
final localeName = PlatformFFI.localeName; final localeName = PlatformFFI.localeName;
RustdeskImpl get bind => platformFFI.ffiBind; RustdeskImpl get bind => platformFFI.ffiBind;
String ffiGetByName(String name, [String arg = '']) {
return PlatformFFI.getByName(name, arg);
}
void ffiSetByName(String name, [String value = '']) {
PlatformFFI.setByName(name, value);
}

View File

@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/mobile/pages/settings_page.dart';
import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -176,6 +177,11 @@ class ServerModel with ChangeNotifier {
await timerCallback(); await timerCallback();
}); });
} }
// Initial keyboard status is off on mobile
if (isMobile) {
bind.mainSetOption(key: kOptionEnableKeyboard, value: 'N');
}
} }
/// 1. check android permission /// 1. check android permission
@@ -190,7 +196,7 @@ class ServerModel with ChangeNotifier {
bind.mainSetOption(key: kOptionEnableAudio, value: "N"); bind.mainSetOption(key: kOptionEnableAudio, value: "N");
} else { } else {
final audioOption = await bind.mainGetOption(key: kOptionEnableAudio); final audioOption = await bind.mainGetOption(key: kOptionEnableAudio);
_audioOk = audioOption.isEmpty; _audioOk = audioOption != 'N';
} }
// file // file
@@ -200,7 +206,7 @@ class ServerModel with ChangeNotifier {
} else { } else {
final fileOption = final fileOption =
await bind.mainGetOption(key: kOptionEnableFileTransfer); await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption.isEmpty; _fileOk = fileOption != 'N';
} }
notifyListeners(); notifyListeners();
@@ -226,8 +232,7 @@ class ServerModel with ChangeNotifier {
_approveMode = approveMode; _approveMode = approveMode;
update = true; update = true;
} }
var stopped = option2bool( var stopped = await mainGetBoolOption(kOptionStopService);
"stop-service", await bind.mainGetOption(key: "stop-service"));
final oldPwdText = _serverPasswd.text; final oldPwdText = _serverPasswd.text;
if (stopped || if (stopped ||
verificationMethod == kUsePermanentPassword || verificationMethod == kUsePermanentPassword ||
@@ -340,6 +345,20 @@ class ServerModel with ChangeNotifier {
return res; return res;
} }
Future<bool> checkFloatingWindowPermission() async {
debugPrint("androidVersion $androidVersion");
if (androidVersion < 23) {
return false;
}
if (await AndroidPermissionManager.check(kSystemAlertWindow)) {
debugPrint("alert window permission already granted");
return true;
}
var res = await AndroidPermissionManager.request(kSystemAlertWindow);
debugPrint("alert window permission request result: $res");
return res;
}
/// Toggle the screen sharing service. /// Toggle the screen sharing service.
toggleService() async { toggleService() async {
if (_isStart) { if (_isStart) {
@@ -367,6 +386,9 @@ class ServerModel with ChangeNotifier {
} }
} else { } else {
await checkRequestNotificationPermission(); await checkRequestNotificationPermission();
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
await checkFloatingWindowPermission();
}
if (!await AndroidPermissionManager.check(kManageExternalStorage)) { if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
await AndroidPermissionManager.request(kManageExternalStorage); await AndroidPermissionManager.request(kManageExternalStorage);
} }
@@ -405,7 +427,7 @@ class ServerModel with ChangeNotifier {
await bind.mainStartService(); await bind.mainStartService();
updateClientState(); updateClientState();
if (isAndroid) { if (isAndroid) {
WakelockPlus.enable(); androidUpdatekeepScreenOn();
} }
} }
@@ -498,6 +520,7 @@ class ServerModel with ChangeNotifier {
} }
if (_clients.length != oldClientLenght) { if (_clients.length != oldClientLenght) {
notifyListeners(); notifyListeners();
if (isAndroid) androidUpdatekeepScreenOn();
} }
} }
@@ -532,6 +555,7 @@ class ServerModel with ChangeNotifier {
scrollToBottom(); scrollToBottom();
notifyListeners(); notifyListeners();
if (isAndroid && !client.authorized) showLoginDialog(client); if (isAndroid && !client.authorized) showLoginDialog(client);
if (isAndroid) androidUpdatekeepScreenOn();
} catch (e) { } catch (e) {
debugPrint("Failed to call loginRequest,error:$e"); debugPrint("Failed to call loginRequest,error:$e");
} }
@@ -652,6 +676,7 @@ class ServerModel with ChangeNotifier {
final index = _clients.indexOf(client); final index = _clients.indexOf(client);
tabController.remove(index); tabController.remove(index);
_clients.remove(client); _clients.remove(client);
if (isAndroid) androidUpdatekeepScreenOn();
} }
} }
@@ -675,6 +700,7 @@ class ServerModel with ChangeNotifier {
if (desktopType == DesktopType.cm && _clients.isEmpty) { if (desktopType == DesktopType.cm && _clients.isEmpty) {
hideCmWindow(); hideCmWindow();
} }
if (isAndroid) androidUpdatekeepScreenOn();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint("onClientRemove failed,error:$e"); debugPrint("onClientRemove failed,error:$e");
@@ -686,6 +712,7 @@ class ServerModel with ChangeNotifier {
_clients.map((client) => bind.cmCloseConnection(connId: client.id))); _clients.map((client) => bind.cmCloseConnection(connId: client.id)));
_clients.clear(); _clients.clear();
tabController.state.value.tabs.clear(); tabController.state.value.tabs.clear();
if (isAndroid) androidUpdatekeepScreenOn();
} }
void jumpTo(int id) { void jumpTo(int id) {
@@ -723,6 +750,27 @@ class ServerModel with ChangeNotifier {
debugPrint("updateVoiceCallState failed: $e"); debugPrint("updateVoiceCallState failed: $e");
} }
} }
void androidUpdatekeepScreenOn() async {
if (!isAndroid) return;
var floatingWindowDisabled =
bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) == "Y" ||
!await AndroidPermissionManager.check(kSystemAlertWindow);
final keepScreenOn = floatingWindowDisabled
? KeepScreenOn.never
: optionToKeepScreenOn(
bind.mainGetLocalOption(key: kOptionKeepScreenOn));
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
(keepScreenOn == KeepScreenOn.duringControlled &&
_clients.map((e) => !e.disconnected).isNotEmpty);
if (on != await WakelockPlus.enabled) {
if (on) {
WakelockPlus.enable();
} else {
WakelockPlus.disable();
}
}
}
} }
enum ClientType { enum ClientType {
@@ -778,7 +826,7 @@ class Client {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id; data['id'] = id;
data['is_start'] = authorized; data['authorized'] = authorized;
data['is_file_transfer'] = isFileTransfer; data['is_file_transfer'] = isFileTransfer;
data['port_forward'] = portForward; data['port_forward'] = portForward;
data['name'] = name; data['name'] = name;
@@ -792,6 +840,8 @@ class Client {
data['block_input'] = blockInput; data['block_input'] = blockInput;
data['disconnected'] = disconnected; data['disconnected'] = disconnected;
data['from_switch'] = fromSwitch; data['from_switch'] = fromSwitch;
data['in_voice_call'] = inVoiceCall;
data['incoming_voice_call'] = incomingVoiceCall;
return data; return data;
} }

View File

@@ -14,11 +14,16 @@ class StateGlobal {
bool _isMinimized = false; bool _isMinimized = false;
final RxBool isMaximized = false.obs; final RxBool isMaximized = false.obs;
final RxBool _showTabBar = true.obs; final RxBool _showTabBar = true.obs;
final RxDouble _resizeEdgeSize = RxDouble(windowEdgeSize); final RxDouble _resizeEdgeSize = RxDouble(windowResizeEdgeSize);
final RxDouble _windowBorderWidth = RxDouble(kWindowBorderWidth); final RxDouble _windowBorderWidth = RxDouble(kWindowBorderWidth);
final RxBool showRemoteToolBar = false.obs; final RxBool showRemoteToolBar = false.obs;
final svcStatus = SvcStatus.notReady.obs; final svcStatus = SvcStatus.notReady.obs;
final RxBool isFocused = false.obs; final RxBool isFocused = false.obs;
// for mobile and web
bool isInMainPage = true;
bool isWebVisible = true;
final isPortrait = false.obs;
String _inputSource = ''; String _inputSource = '';
@@ -68,32 +73,45 @@ class StateGlobal {
if (_fullscreen.value != v) { if (_fullscreen.value != v) {
_fullscreen.value = v; _fullscreen.value = v;
_showTabBar.value = !_fullscreen.value; _showTabBar.value = !_fullscreen.value;
if (isWebDesktop) {
procFullscreenWeb();
} else {
procFullscreenNative(procWnd);
}
}
}
procFullscreenWeb() {
final isFullscreen = ffiGetByName('fullscreen') == 'Y';
String fullscreenValue = '';
if (isFullscreen && _fullscreen.isFalse) {
fullscreenValue = 'N';
} else if (!isFullscreen && fullscreen.isTrue) {
fullscreenValue = 'Y';
}
if (fullscreenValue.isNotEmpty) {
ffiSetByName('fullscreen', fullscreenValue);
}
}
procFullscreenNative(bool procWnd) {
refreshResizeEdgeSize(); refreshResizeEdgeSize();
print( print("fullscreen: $fullscreen, resizeEdgeSize: ${_resizeEdgeSize.value}");
"fullscreen: $fullscreen, resizeEdgeSize: ${_resizeEdgeSize.value}");
_windowBorderWidth.value = fullscreen.isTrue ? 0 : kWindowBorderWidth; _windowBorderWidth.value = fullscreen.isTrue ? 0 : kWindowBorderWidth;
if (procWnd) { if (procWnd) {
final wc = WindowController.fromWindowId(windowId); final wc = WindowController.fromWindowId(windowId);
wc.setFullscreen(_fullscreen.isTrue).then((_) { wc.setFullscreen(_fullscreen.isTrue).then((_) {
// https://github.com/leanflutter/window_manager/issues/131#issuecomment-1111587982 // We remove the redraw (width + 1, height + 1), because this issue cannot be reproduced.
if (isWindows && !v) { // https://github.com/rustdesk/rustdesk/issues/9675
Future.delayed(Duration.zero, () async {
final frame = await wc.getFrame();
final newRect = Rect.fromLTWH(
frame.left, frame.top, frame.width + 1, frame.height + 1);
await wc.setFrame(newRect);
}); });
} }
});
}
}
} }
refreshResizeEdgeSize() => _resizeEdgeSize.value = fullscreen.isTrue refreshResizeEdgeSize() => _resizeEdgeSize.value = fullscreen.isTrue
? kFullScreenEdgeSize ? kFullScreenEdgeSize
: isMaximized.isTrue : isMaximized.isTrue
? kMaximizeEdgeSize ? kMaximizeEdgeSize
: windowEdgeSize; : windowResizeEdgeSize;
String getInputSource({bool force = false}) { String getInputSource({bool force = false}) {
if (force || _inputSource.isEmpty) { if (force || _inputSource.isEmpty) {
@@ -107,7 +125,13 @@ class StateGlobal {
_inputSource = bind.mainGetInputSource(); _inputSource = bind.mainGetInputSource();
} }
StateGlobal._(); StateGlobal._() {
if (isWebDesktop) {
platformFFI.setFullscreenCallback((v) {
_fullscreen.value = v;
});
}
}
static final StateGlobal instance = StateGlobal._(); static final StateGlobal instance = StateGlobal._();
} }

View File

@@ -17,13 +17,23 @@ bool refreshingUser = false;
class UserModel { class UserModel {
final RxString userName = ''.obs; final RxString userName = ''.obs;
final RxBool isAdmin = false.obs; final RxBool isAdmin = false.obs;
final RxString networkError = ''.obs;
bool get isLogin => userName.isNotEmpty; bool get isLogin => userName.isNotEmpty;
WeakReference<FFI> parent; WeakReference<FFI> parent;
UserModel(this.parent); UserModel(this.parent) {
userName.listen((p0) {
// When user name becomes empty, show login button
// When user name becomes non-empty:
// For _updateLocalUserInfo, network error will be set later
// For login success, should clear network error
networkError.value = '';
});
}
void refreshCurrentUser() async { void refreshCurrentUser() async {
if (bind.isDisableAccount()) return; if (bind.isDisableAccount()) return;
networkError.value = '';
final token = bind.mainGetLocalOption(key: 'access_token'); final token = bind.mainGetLocalOption(key: 'access_token');
if (token == '') { if (token == '') {
await updateOtherModels(); await updateOtherModels();
@@ -38,12 +48,18 @@ class UserModel {
if (refreshingUser) return; if (refreshingUser) return;
try { try {
refreshingUser = true; refreshingUser = true;
final response = await http.post(Uri.parse('$url/api/currentUser'), final http.Response response;
try {
response = await http.post(Uri.parse('$url/api/currentUser'),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer $token' 'Authorization': 'Bearer $token'
}, },
body: json.encode(body)); body: json.encode(body));
} catch (e) {
networkError.value = e.toString();
rethrow;
}
refreshingUser = false; refreshingUser = false;
final status = response.statusCode; final status = response.statusCode;
if (status == 401 || status == 400) { if (status == 401 || status == 400) {

View File

@@ -1,12 +1,14 @@
// ignore_for_file: avoid_web_libraries_in_flutter // ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:convert'; import 'dart:convert';
import 'dart:js_interop';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:js'; import 'dart:js';
import 'dart:html'; import 'dart:html';
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/web/bridge.dart'; import 'package:flutter_hbb/web/bridge.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
@@ -28,7 +30,15 @@ class PlatformFFI {
context.callMethod('setByName', [name, value]); context.callMethod('setByName', [name, value]);
} }
PlatformFFI._(); PlatformFFI._() {
window.document.addEventListener(
'visibilitychange',
(event) => {
stateGlobal.isWebVisible =
window.document.visibilityState == 'visible'
});
}
static final PlatformFFI instance = PlatformFFI._(); static final PlatformFFI instance = PlatformFFI._();
static get localeName => window.navigator.language; static get localeName => window.navigator.language;
@@ -98,6 +108,10 @@ class PlatformFFI {
sessionId: sessionId, display: display, ptr: ptr); sessionId: sessionId, display: display, ptr: ptr);
Future<void> init(String appType) async { Future<void> init(String appType) async {
Completer completer = Completer();
context["onInitFinished"] = () {
completer.complete();
};
context.callMethod('init'); context.callMethod('init');
version = getByName('version'); version = getByName('version');
window.onContextMenu.listen((event) { window.onContextMenu.listen((event) {
@@ -112,6 +126,7 @@ class PlatformFFI {
print('json.decode fail(): $e'); print('json.decode fail(): $e');
} }
}; };
return completer.future;
} }
void setEventCallback(void Function(Map<String, dynamic>) fun) { void setEventCallback(void Function(Map<String, dynamic>) fun) {
@@ -157,4 +172,10 @@ class PlatformFFI {
// just for compilation // just for compilation
void syncAndroidServiceAppDirConfigPath() {} void syncAndroidServiceAppDirConfigPath() {}
void setFullscreenCallback(void Function(bool) fun) {
context["onFullscreenChanged"] = (bool v) {
fun(v);
};
}
} }

Some files were not shown because too many files have changed in this diff Show More