528 Commits

301 changed files with 21823 additions and 8253 deletions

View File

@@ -1,8 +1,14 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]
[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")']
rustflags = [
"-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:
env:
FLUTTER_VERSION: "3.16.9"
FLUTTER_VERSION: "3.19.6"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503

View File

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

@@ -10,15 +10,15 @@ 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.13.9"
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.03.25
VCPKG_COMMIT_ID: "a34c873a9717a888f58dc05268dea15592c2f0ff"
VERSION: "1.2.6"
# 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 }}"
@@ -31,7 +31,207 @@ env:
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:
@@ -62,7 +262,7 @@ jobs:
git \
g++ \
g++-multilib \
libappindicator3-dev \
libayatana-appindicator3-dev\
libasound2-dev \
libc6-dev \
libclang-10-dev \

View File

@@ -6,7 +6,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: vedantmgoyal2009/winget-releaser@v2
- uses: vedantmgoyal9/winget-releaser@main
with:
identifier: RustDesk.RustDesk
version: ${{ github.event.release.tag_name }}

1
.gitignore vendored
View File

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

2720
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk"
version = "1.2.6"
version = "1.3.2"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"
@@ -66,7 +66,7 @@ default-net = "0.14"
wol-rs = "1.0"
flutter_rust_bridge = { version = "=1.80", features = ["uuid"], optional = true}
errno = "0.3"
rdev = { git = "https://github.com/fufesou/rdev" }
rdev = { git = "https://github.com/rustdesk-org/rdev" }
url = { version = "2.3", features = ["serde"] }
crossbeam-queue = "0.3"
hex = "0.4"
@@ -89,7 +89,10 @@ sys-locale = "0.3"
enigo = { path = "libs/enigo", features = [ "with_serde" ] }
clipboard = { path = "libs/clipboard" }
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"
qrcode-generator = "4.1"
@@ -111,7 +114,7 @@ winapi = { version = "0.3", features = [
winreg = "0.11"
windows-service = "0.6"
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"
tauri-winrt-notification = "0.1.2"
runas = "1.2"
@@ -135,7 +138,7 @@ image = "0.24"
keepawake = { git = "https://github.com/rustdesk-org/keepawake-rs" }
[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]
# 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" }
rust-pulsectl = { git = "https://github.com/open-trade/pulsectl" }
async-process = "1.7"
mouce = { git="https://github.com/fufesou/mouce.git" }
evdev = { git="https://github.com/fufesou/evdev" }
evdev = { git="https://github.com/rustdesk-org/evdev" }
dbus = "0.9"
dbus-crossroads = "0.5"
pam = { git="https://github.com/fufesou/pam" }
pam = { git="https://github.com/rustdesk-org/pam" }
users = { version = "0.11" }
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
percent-encoding = {version = "2.3", optional = true}
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]
android_logger = "0.13"
jni = "0.21"
android-wakelock = { git = "https://github.com/21pages/android-wakelock" }
android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
[workspace]
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable"]

View File

@@ -1,6 +1,6 @@
<p align="center">
<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="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> •
@@ -59,19 +59,19 @@ Please download Sciter dynamic library yourself.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
```
### Arch (Manjaro)
@@ -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)
![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
name: rustdesk
icon: rustdesk
version: 1.2.6
version: 1.3.2
exec: usr/lib/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.2.6
version: 1.3.2
exec: usr/lib/rustdesk/rustdesk
exec_args: $@
apt:
@@ -37,6 +37,9 @@ AppDir:
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal-security main restricted
universe multiverse
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
- libgtk-3-0
- libxcb-randr0

View File

@@ -25,12 +25,17 @@ flutter_build_dir_2 = f'flutter/{flutter_build_dir}'
skip_cargo = False
def get_arch() -> str:
custom_arch = os.environ.get("ARCH")
def get_deb_arch() -> str:
custom_arch = os.environ.get("DEB_ARCH")
if custom_arch is None:
return "amd64"
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):
exit_code = os.system(cmd)
@@ -48,15 +53,7 @@ def get_version():
def parse_rc_features(feature):
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'],
}
}
available_features = {}
apply_features = {}
if not feature:
feature = []
@@ -81,7 +78,6 @@ def parse_rc_features(feature):
elif isinstance(feature, list):
if windows:
# download third party is deprecated, we use github ci instead.
# force add PrivacyMode
# feature.append('PrivacyMode')
pass
for feat in feature:
@@ -108,7 +104,7 @@ def make_parser():
nargs='+',
default='',
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',
help='Build flutter package', default=False)
parser.add_argument(
@@ -287,14 +283,17 @@ def generate_control_file(version):
system2('/bin/rm -rf %s' % control_file_path)
content = """Package: rustdesk
Section: net
Priority: optional
Version: %s
Architecture: %s
Maintainer: rustdesk <info@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.
""" % (version, get_arch())
""" % (version, get_deb_arch(), get_deb_extra_depends())
file = open(control_file_path, "w")
file.write(content)
file.close()
@@ -334,8 +333,6 @@ def build_flutter_deb(version, features):
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2(
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
'cp ../res/com.rustdesk.RustDesk.policy tmpdeb/usr/share/polkit-1/actions/')
system2(
'cp ../res/startwm.sh tmpdeb/etc/rustdesk/')
system2(
@@ -379,8 +376,6 @@ def build_deb_from_folder(version, binary_folder):
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2(
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
'cp ../res/com.rustdesk.RustDesk.policy tmpdeb/usr/share/polkit-1/actions/')
system2(
"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">
<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="#raw-steps-to-build">Build</a> •
<a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> •
<a href="#snapshot">Snapshot</a><br>
[<a href="../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>
<b>このREADMEをあなたの母国語に翻訳するために、あなたの助けが必要です。</b>
[<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や<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>
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)
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)
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) |
[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)
モバイル版はFlutterを利用します。デスクトップ版もSciterからFlutterへマイグレーション予定です。
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
## ビルド手順
- Rust開発環境とC ++ビルド環境を準備します
- Rust開発環境とC++ビルド環境を準備します
- [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`
- [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
- `cargo run`を実行します。
## [ビルド](https://rustdesk.com/docs/en/dev/build/)
## Linuxでのビルド手順
## Linuxでのビルド方法
### Ubuntu 18 (Debian 10)
```sh
sudo apt install -y g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### 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)
@@ -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
```
### Install vcpkg
### vcpkgのインストール
```sh
git clone https://github.com/microsoft/vcpkg
@@ -81,7 +94,7 @@ export VCPKG_ROOT=$HOME/vcpkg
vcpkg/vcpkg install libvpx libyuv opus aom
```
### Fix libvpx (For Fedora)
### libvpxの修正 (Fedoraのみ)
```sh
cd vcpkg/buildtrees/libvpx/src
@@ -107,9 +120,9 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run
```
## Dockerでビルドする方法
## Dockerでビルド方法
リポジトリクローンを作成し、Dockerコンテナを構築することから始めます
リポジトリクローンし、Dockerコンテナを構築ます:
```sh
git clone https://github.com/rustdesk/rustdesk
@@ -117,44 +130,50 @@ cd rustdesk
docker build -t "rustdesk-builder" .
```
その後、アプリケーションをビルドする必要があるたびに、以下のコマンドを実行します
以下のコマンドを実行します:
```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
```
このコマンドはRustDeskをビルドする度に実行する必要があります。
なお、最初のビルドでは、依存関係がキャッシュされるまで時間がかかることがありますが、その後のビルドではより速くなります。さらに、ビルドコマンドに別の引数を指定する必要がある場合は、コマンドの最後にある `<OPTIONAL-ARGS>` の位置で指定することができます。例えば、最適化されたリリースバージョンをビルドしたい場合は、上記のコマンドの後に
`--release` を実行します。できあがった実行ファイルはシステムのターゲットフォルダに格納され、のコマンドで実行できます。
初回ビルドは時間がかかるかもしれませんが、2回目以降は依存関係がキャッシュされるため、ビルドにかかる時間が短くなります。
ビルドコマンドに追加の引数を指定する必要がある場合は、コマンドの最後(`<OPTIONAL-ARGS>`の位置)で指定することができます。例えば、最適化されたリリースバージョンをビルドしたい場合は、上記のコマンドの後に `--release`追記し実行します。ビルドされた実行ファイルはあなたのシステムのターゲットフォルダに保存され、下記のコマンドで実行することができます。
デバッグビルドを起動する場合:
```sh
target/debug/rustdesk
```
あるいは、リリース用の実行ファイルを実行している場合:
リリースビルドを起動する場合:
```sh
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/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: プラットフォーム固有のキーボード/マウスコントロール
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: オーディオ/クリップボード/入力/ビデオサービス、ネットワーク接続
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: プラットフォーム固有のキーボード/マウス操作
- **[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/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)**: プラットフォーム固有のコード
- **[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">
<img src="../res/logo-header.svg" alt="RustDesk - Ваша віддалена стільниця"><br>
<a href="#безкоштовні-загальнодоступні-сервери">Сервери</a> •
<a href="#публічні-сервери">Сервери</a> •
<a href="#кроки-для-збірки">Збирання</a> •
<a href="#як-зібрати-за-допомогою-docker">Docker</a> •
<a href="#структура-файлів">Структура</a> •
<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>
<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>
<a href="#знімки-екрана">Знімки екрана</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>
</p>
Спілкування з нами: [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)
[![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).
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
@@ -61,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
```
### Arch (Manjaro)
@@ -158,18 +156,22 @@ target/release/rustdesk
- **[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/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервіси аудіо/буфера обміну/вводу/відео та мережевих підключень
- **[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/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/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для Flutter веб клієнту
- **[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>
</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)
@@ -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)
RustDesk 期待各位的贡献. 如何参与开发? 详情请看 [CONTRIBUTING.md](CONTRIBUTING.md).
RustDesk 期待各位的贡献. 如何参与开发? 详情请看 [CONTRIBUTING-ZH.md](CONTRIBUTING-ZH.md).
[**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) |
[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/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 屏幕截取
- **[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/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/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代码
## 截图

View File

@@ -18,7 +18,9 @@ import android.widget.EditText
import android.view.accessibility.AccessibilityEvent
import android.view.ViewGroup.LayoutParams
import android.view.accessibility.AccessibilityNodeInfo
import android.view.KeyEvent as KeyEventAndroid
import android.graphics.Rect
import android.media.AudioManager
import android.accessibilityservice.AccessibilityServiceInfo
import android.accessibilityservice.AccessibilityServiceInfo.FLAG_INPUT_METHOD_EDITOR
import android.accessibilityservice.AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
@@ -75,6 +77,8 @@ class InputService : AccessibilityService() {
private var fakeEditTextForTextStateCalculation: EditText? = null
private val volumeController: VolumeController by lazy { VolumeController(applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager) }
@RequiresApi(Build.VERSION_CODES.N)
fun onMouseInput(mask: Int, _x: Int, _y: Int) {
val x = max(0, _x)
@@ -294,6 +298,18 @@ class InputService : AccessibilityService() {
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) {
getInputMethod()?.let { inputMethod ->
inputMethod.getCurrentInputConnection()?.let { inputConnection ->
@@ -302,7 +318,7 @@ class InputService : AccessibilityService() {
inputConnection.commitText(text, 1, null)
}
} else {
KeyEventConverter.toAndroidKeyEvent(keyEvent).let { event ->
ke?.let { event ->
inputConnection.sendKeyEvent(event)
}
}
@@ -311,7 +327,7 @@ class InputService : AccessibilityService() {
} else {
val handler = Handler(Looper.getMainLooper())
handler.post {
KeyEventConverter.toAndroidKeyEvent(keyEvent)?.let { event ->
ke?.let { event ->
val possibleNodes = possibleAccessibiltyNodes()
Log.d(logTag, "possibleNodes:$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) {
if (node == null) {
return
@@ -422,7 +475,7 @@ class InputService : AccessibilityService() {
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()
this.fakeEditTextForTextStateCalculation?.setSelection(0,0)
this.fakeEditTextForTextStateCalculation?.setText(null)
@@ -487,10 +540,10 @@ class InputService : AccessibilityService() {
it.layout(rect.left, rect.top, rect.right, rect.bottom)
it.onPreDraw()
if (event.action == android.view.KeyEvent.ACTION_DOWN) {
if (event.action == KeyEventAndroid.ACTION_DOWN) {
val succ = it.onKeyDown(event.getKeyCode(), event)
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)
Log.d(logTag, "keyup $success")
} else {}

View File

@@ -37,6 +37,8 @@ object KeyEventConverter {
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)
}
@@ -112,6 +114,10 @@ object KeyEventConverter {
ControlKey.Delete -> KeyEvent.KEYCODE_FORWARD_DEL
ControlKey.Clear -> KeyEvent.KEYCODE_CLEAR
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.
}
}

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

@@ -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

@@ -30,8 +30,8 @@ import 'common/widgets/overlay.dart';
import 'mobile/pages/file_manager_page.dart';
import 'mobile/pages/remote_page.dart';
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 'models/input_model.dart';
import 'models/model.dart';
import 'models/platform_model.dart';
@@ -51,6 +51,9 @@ final isLinux = isLinux_;
final isDesktop = isDesktop_;
final isWeb = isWeb_;
final isWebDesktop = isWebDesktop_;
final isWebOnWindows = isWebOnWindows_;
final isWebOnLinux = isWebOnLinux_;
final isWebOnMacOs = isWebOnMacOS_;
var isMobile = isAndroid || isIOS;
var version = '';
int androidVersion = 0;
@@ -348,6 +351,9 @@ class MyTheme {
hoverColor: Color.fromARGB(255, 224, 224, 224),
scaffoldBackgroundColor: Colors.white,
dialogBackgroundColor: Colors.white,
appBarTheme: AppBarTheme(
shadowColor: Colors.transparent,
),
dialogTheme: DialogTheme(
elevation: 15,
shape: RoundedRectangleBorder(
@@ -443,6 +449,9 @@ class MyTheme {
hoverColor: Color.fromARGB(255, 45, 46, 53),
scaffoldBackgroundColor: Color(0xFF18191E),
dialogBackgroundColor: Color(0xFF18191E),
appBarTheme: AppBarTheme(
shadowColor: Colors.transparent,
),
dialogTheme: DialogTheme(
elevation: 15,
shape: RoundedRectangleBorder(
@@ -546,9 +555,9 @@ class MyTheme {
return themeModeFromString(bind.mainGetLocalOption(key: kCommConfKeyTheme));
}
static void changeDarkMode(ThemeMode mode) async {
static Future<void> changeDarkMode(ThemeMode mode) async {
Get.changeThemeMode(mode);
if (desktopType == DesktopType.main || isAndroid || isIOS) {
if (desktopType == DesktopType.main || isAndroid || isIOS || isWeb) {
if (mode == ThemeMode.system) {
await bind.mainSetLocalOption(
key: kCommConfKeyTheme, value: defaultOptionTheme);
@@ -556,7 +565,7 @@ class MyTheme {
await bind.mainSetLocalOption(
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.
updateSystemWindowTheme();
}
@@ -630,10 +639,30 @@ List<Locale> supportedLocales = const [
Locale('da'),
Locale('eo'),
Locale('tr'),
Locale('vi'),
Locale('pl'),
Locale('kz'),
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) {
@@ -647,11 +676,17 @@ String formatDurationToTime(Duration duration) {
closeConnection({String? id}) {
if (isAndroid || isIOS) {
gFFI.chatModel.hideChatOverlay();
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
gFFI.chatModel.hideChatOverlay();
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
stateGlobal.isInMainPage = true;
}();
} else {
if (isWeb) {
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
stateGlobal.isInMainPage = true;
} else {
final controller = Get.find<DesktopTabController>();
controller.closeBy(id);
@@ -928,13 +963,9 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
position: draggablePositions.mobileActions,
width: overlayW,
height: overlayH,
onBackPressed: () => session.inputModel.tap(MouseButtons.right),
onHomePressed: () => session.inputModel.tap(MouseButtons.wheel),
onRecentPressed: () async {
session.inputModel.sendMouse('down', MouseButtons.wheel);
await Future.delayed(const Duration(milliseconds: 500));
session.inputModel.sendMouse('up', MouseButtons.wheel);
},
onBackPressed: session.inputModel.onMobileBack,
onHomePressed: session.inputModel.onMobileHome,
onRecentPressed: session.inputModel.onMobileApps,
onHidePressed: onHide,
);
}
@@ -1058,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,
String link, OverlayDialogManager dialogManager,
{bool? hasCancel, ReconnectHandle? reconnect, int? reconnectTimeout}) {
@@ -1066,7 +1140,7 @@ void msgBox(SessionID sessionId, String type, String title, String text,
bool hasOk = false;
submit() {
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) {
closeConnection();
}
@@ -1100,21 +1174,33 @@ void msgBox(SessionID sessionId, String type, String title, String text,
dialogManager.dismissAll();
}));
}
if (reconnect != null &&
title == "Connection Error" &&
reconnectTimeout != null) {
if (reconnect != null && title == "Connection Error") {
// `enabled` is used to disable the dialog button once the button is clicked.
final enabled = true.obs;
final button = Obx(() => _ReconnectCountDownButton(
second: reconnectTimeout,
onPressed: enabled.isTrue
? () {
// Disable the button
enabled.value = false;
reconnect(dialogManager, sessionId, false);
}
: null,
));
final button = reconnectTimeout != null
? Obx(() => _ReconnectCountDownButton(
second: reconnectTimeout,
onPressed: enabled.isTrue
? () {
// Disable the button
enabled.value = false;
reconnect(dialogManager, sessionId, false);
}
: null,
))
: Obx(
() => dialogButton(
'Reconnect',
isOutline: true,
onPressed: enabled.isTrue
? () {
// Disable the button
enabled.value = false;
reconnect(dialogManager, sessionId, false);
}
: null,
),
);
buttons.insert(0, button);
}
if (link.isNotEmpty) {
@@ -1203,7 +1289,7 @@ Widget msgboxContent(String type, String title, String text) {
translate(title),
style: TextStyle(fontSize: 21),
).marginOnly(bottom: 10),
Text(translateText(text), style: const TextStyle(fontSize: 15)),
createDialogContent(translateText(text)),
],
),
),
@@ -1398,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>(
Widget title, T toValue, T curValue, ValueChanged<T?>? onChange,
{EdgeInsetsGeometry? contentPadding, bool? dense}) {
{bool? dense}) {
return RadioListTile<T>(
contentPadding: contentPadding ?? EdgeInsets.zero,
visualDensity: VisualDensity.compact,
controlAffinity: ListTileControlAffinity.trailing,
title: title,
@@ -1432,7 +1514,7 @@ Future<void> initGlobalFFI() async {
_globalFFI = FFI(null);
debugPrint("_globalFFI init end");
// 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) {
@@ -1956,6 +2038,8 @@ Future<bool> restoreWindowPosition(WindowType type,
return false;
}
var webInitialLink = "";
/// Initialize uni links for macos/windows
///
/// [Availability]
@@ -1972,7 +2056,12 @@ Future<bool> initUniLinks() async {
if (initialLink == null || initialLink.isEmpty) {
return false;
}
return handleUriLink(uriString: initialLink);
if (isWeb) {
webInitialLink = initialLink;
return false;
} else {
return handleUriLink(uriString: initialLink);
}
} catch (err) {
debugPrintStack(label: "$err");
return false;
@@ -1985,7 +2074,7 @@ Future<bool> initUniLinks() async {
///
/// Returns a [StreamSubscription] which can listen the uni links.
StreamSubscription? listenUniLinks({handleByFlutter = true}) {
if (isLinux) {
if (isLinux || isWeb) {
return null;
}
@@ -2289,20 +2378,35 @@ connect(BuildContext context, String id,
}
} else {
if (isFileTransfer) {
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
return;
if (isAndroid) {
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
return;
}
}
}
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => FileManagerPage(
id: id, password: password, isSharedPassword: isSharedPassword),
),
);
if (isWeb) {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) =>
desktop_file_manager.FileManagerPage(
id: id,
password: password,
isSharedPassword: isSharedPassword),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => FileManagerPage(
id: id, password: password, isSharedPassword: isSharedPassword),
),
);
}
} else {
if (isWebDesktop) {
if (isWeb) {
Navigator.push(
context,
MaterialPageRoute(
@@ -2326,6 +2430,7 @@ connect(BuildContext context, String id,
);
}
}
stateGlobal.isInMainPage = false;
}
FocusScopeNode currentFocus = FocusScope.of(context);
@@ -2719,20 +2824,26 @@ Future<void> shouldBeBlocked(RxBool block, WhetherUseRemoteBlock? use) async {
}
typedef WhetherUseRemoteBlock = Future<bool> Function();
Widget buildRemoteBlock({required Widget child, WhetherUseRemoteBlock? use}) {
var block = false.obs;
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,
child: Stack(children: [
child,
Offstage(
offstage: !block.value,
child: Container(
color: Colors.black.withOpacity(0.5),
)),
// 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: !block.value,
child: Container(
color: Colors.black.withOpacity(0.5),
)),
]),
));
}
@@ -2800,7 +2911,7 @@ Widget buildErrorBanner(BuildContext context,
alignment: Alignment.centerLeft,
child: Tooltip(
message: translate(err.value),
child: Text(
child: SelectableText(
translate(err.value),
),
)).marginSymmetric(vertical: 2),
@@ -2921,6 +3032,16 @@ openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
final displays = i == kAllDisplayValue
? List.generate(pi.displays.length, (index) => index)
: [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(
isDesktop: isDesktop,
sessionId: ffi.sessionId,
@@ -2954,11 +3075,15 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
kMainWindowId, kWindowEventOpenMonitorSession, jsonEncode(args));
}
setNewConnectWindowFrame(
int windowId, String peerId, int? display, Rect? screenRect) async {
setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount,
int? display, Rect? screenRect) async {
if (screenRect == null) {
await restoreWindowPosition(WindowType.RemoteDesktop,
windowId: windowId, display: display, peerId: peerId);
// 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,
windowId: windowId, display: display, peerId: peerId);
}
} else {
await tryMoveToScreenAndSetFullscreen(screenRect);
}
@@ -3055,9 +3180,13 @@ class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> {
importConfig(List<TextEditingController>? controllers, List<RxString>? errMsgs,
String? text) {
text = text?.trim();
if (text != null && text.isNotEmpty) {
try {
final sc = ServerConfig.decode(text);
if (isWeb || isIOS) {
sc.relayServer = '';
}
if (sc.idServer.isNotEmpty) {
Future<bool> success = setServerConfig(controllers, errMsgs, sc);
success.then((value) {
@@ -3084,9 +3213,16 @@ Future<bool> setServerConfig(
List<RxString>? errMsgs,
ServerConfig config,
) async {
config.idServer = config.idServer.trim();
config.relayServer = config.relayServer.trim();
config.apiServer = config.apiServer.trim();
String removeEndSlash(String input) {
if (input.endsWith('/')) {
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();
if (controllers != null) {
controllers[0].text = config.idServer;
@@ -3295,6 +3431,42 @@ bool isInHomePage() {
return controller.state.value.selected == 0;
}
Widget _buildPresetPasswordWarning() {
if (bind.mainGetBuildinOption(key: kOptionRemovePresetPasswordWarning) !=
'N') {
return SizedBox.shrink();
}
return Container(
color: Colors.yellow,
child: Column(
children: [
Align(
child: Text(
translate("Security Alert"),
style: TextStyle(
color: Colors.red,
fontSize:
18, // https://github.com/rustdesk/rustdesk-server-pro/issues/261
fontWeight: FontWeight.bold,
),
)).paddingOnly(bottom: 8),
Text(
translate("preset_password_warning"),
style: TextStyle(color: Colors.red),
)
],
).paddingAll(8),
); // 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(),
@@ -3305,27 +3477,7 @@ Widget buildPresetPasswordWarning() {
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(
color: Colors.yellow,
child: Column(
children: [
Align(
child: Text(
translate("Security Alert"),
style: TextStyle(
color: Colors.red,
fontSize:
18, // https://github.com/rustdesk/rustdesk-server-pro/issues/261
fontWeight: FontWeight.bold,
),
)).paddingOnly(bottom: 8),
Text(
translate("preset_password_warning"),
style: TextStyle(color: Colors.red),
)
],
).paddingAll(8),
); // Show a warning message if the Future completed with true
return _buildPresetPasswordWarning();
} else {
return SizedBox
.shrink(); // Show nothing if the Future completed with false or null
@@ -3379,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.
// See _linuxWindowResizable for more details.
@@ -3397,7 +3550,12 @@ setResizable(bool resizable) {
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 defaultOptionTheme => isCustomClient ? 'system' : '';
get defaultOptionYes => isCustomClient ? 'Y' : '';
@@ -3406,6 +3564,12 @@ get defaultOptionWhitelist => isCustomClient ? ',' : '';
get defaultOptionAccessMode => isCustomClient ? 'custom' : '';
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.
@@ -3429,3 +3593,40 @@ disableWindowMovable(int? windowId) {
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) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxString>(tag: key)) {
final RxString state = ''.obs;
Get.put(state, tag: key);
Get.put<RxString>(state, tag: key);
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxString>(tag: key)) {
Get.delete<RxString>(tag: key);
} else {
Get.find<RxString>(tag: key).value = '';
}
@@ -33,9 +33,9 @@ class BlockInputState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put(state, tag: key);
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
@@ -43,8 +43,8 @@ class BlockInputState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
@@ -56,9 +56,9 @@ class CurrentDisplayState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = RxInt(0);
Get.put(state, tag: key);
Get.put<RxInt>(state, tag: key);
} else {
Get.find<RxInt>(tag: key).value = 0;
}
@@ -66,8 +66,8 @@ class CurrentDisplayState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete<RxInt>(tag: key);
}
}
@@ -105,16 +105,16 @@ class ConnectionTypeState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<ConnectionType>(tag: key)) {
final ConnectionType collectionType = ConnectionType();
Get.put(collectionType, tag: key);
Get.put<ConnectionType>(collectionType, tag: key);
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<ConnectionType>(tag: key)) {
Get.delete<ConnectionType>(tag: key);
}
}
@@ -127,9 +127,9 @@ class FingerprintState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxString>(tag: key)) {
final RxString state = ''.obs;
Get.put(state, tag: key);
Get.put<RxString>(state, tag: key);
} else {
Get.find<RxString>(tag: key).value = '';
}
@@ -137,8 +137,8 @@ class FingerprintState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxString>(tag: key)) {
Get.delete<RxString>(tag: key);
}
}
@@ -150,9 +150,9 @@ class ShowRemoteCursorState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put(state, tag: key);
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
@@ -160,8 +160,8 @@ class ShowRemoteCursorState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
@@ -173,9 +173,9 @@ class ShowRemoteCursorLockState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put(state, tag: key);
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
@@ -183,8 +183,8 @@ class ShowRemoteCursorLockState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
@@ -196,10 +196,10 @@ class KeyboardEnabledState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxBool>(tag: key)) {
// Server side, default true
final RxBool state = true.obs;
Get.put(state, tag: key);
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = true;
}
@@ -207,8 +207,8 @@ class KeyboardEnabledState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
@@ -220,9 +220,9 @@ class RemoteCursorMovedState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put(state, tag: key);
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
@@ -230,8 +230,8 @@ class RemoteCursorMovedState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
@@ -243,9 +243,9 @@ class RemoteCountState {
static void init() {
final key = tag();
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = 1.obs;
Get.put(state, tag: key);
Get.put<RxInt>(state, tag: key);
} else {
Get.find<RxInt>(tag: key).value = 1;
}
@@ -253,8 +253,8 @@ class RemoteCountState {
static void delete() {
final key = tag();
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete<RxInt>(tag: key);
}
}
@@ -266,9 +266,9 @@ class PeerBoolOption {
static void init(String id, String opt, bool Function() init_getter) {
final key = tag(id, opt);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool value = RxBool(init_getter());
Get.put(value, tag: key);
Get.put<RxBool>(value, tag: key);
} else {
Get.find<RxBool>(tag: key).value = init_getter();
}
@@ -276,8 +276,8 @@ class PeerBoolOption {
static void delete(String id, String opt) {
final key = tag(id, opt);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
@@ -290,9 +290,9 @@ class PeerStringOption {
static void init(String id, String opt, String Function() init_getter) {
final key = tag(id, opt);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxString>(tag: key)) {
final RxString value = RxString(init_getter());
Get.put(value, tag: key);
Get.put<RxString>(value, tag: key);
} else {
Get.find<RxString>(tag: key).value = init_getter();
}
@@ -300,8 +300,8 @@ class PeerStringOption {
static void delete(String id, String opt) {
final key = tag(id, opt);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxString>(tag: key)) {
Get.delete<RxString>(tag: key);
}
}
@@ -314,9 +314,9 @@ class UnreadChatCountState {
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered(tag: key)) {
if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = RxInt(0);
Get.put(state, tag: key);
Get.put<RxInt>(state, tag: key);
} else {
Get.find<RxInt>(tag: key).value = 0;
}
@@ -324,8 +324,8 @@ class UnreadChatCountState {
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered(tag: key)) {
Get.delete(tag: key);
if (Get.isRegistered<RxInt>(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/models/ab_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 '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
import 'package:get/get.dart';
@@ -35,17 +36,14 @@ class AddressBook extends StatefulWidget {
class _AddressBookState extends State<AddressBook> {
var menuPos = RelativeRect.fill;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) => Obx(() {
if (!gFFI.userModel.isLogin) {
return Center(
child: ElevatedButton(
onPressed: loginDialog, child: Text(translate("Login"))));
} else if (gFFI.userModel.networkError.isNotEmpty) {
return netWorkErrorWidget();
} else {
return Column(
children: [
@@ -64,15 +62,16 @@ class _AddressBookState extends State<AddressBook> {
retry: null, // remove retry
close: () => gFFI.abModel.currentAbPushError.value = ''),
Expanded(
child: (isDesktop || isWebDesktop)
? _buildAddressBookDesktop()
: _buildAddressBookMobile())
child: Obx(() => stateGlobal.isPortrait.isTrue
? _buildAddressBookPortrait()
: _buildAddressBookLandscape()),
),
],
);
}
});
Widget _buildAddressBookDesktop() {
Widget _buildAddressBookLandscape() {
return Row(
children: [
Offstage(
@@ -109,7 +108,7 @@ class _AddressBookState extends State<AddressBook> {
);
}
Widget _buildAddressBookMobile() {
Widget _buildAddressBookPortrait() {
const padding = 8.0;
return Column(
children: [
@@ -242,14 +241,15 @@ class _AddressBookState extends State<AddressBook> {
bind.setLocalFlutterOption(k: kOptionCurrentAbName, v: value);
}
},
customButton: Container(
height: isDesktop ? 48 : 40,
child: Row(children: [
Expanded(
child: buildItem(gFFI.abModel.currentName.value, button: true)),
Icon(Icons.arrow_drop_down),
]),
),
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(
height: 0.7,
color: Theme.of(context).dividerColor.withOpacity(0.1),
@@ -338,8 +338,8 @@ class _AddressBookState extends State<AddressBook> {
showActionMenu: editPermission);
}
final gridView = DynamicGridView.builder(
shrinkWrap: isMobile,
gridView(bool isPortrait) => DynamicGridView.builder(
shrinkWrap: isPortrait,
gridDelegate: SliverGridDelegateWithWrapping(),
itemCount: tags.length,
itemBuilder: (BuildContext context, int index) {
@@ -347,9 +347,9 @@ class _AddressBookState extends State<AddressBook> {
return tagBuilder(e);
});
final maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return (isDesktop || isWebDesktop)
? gridView
: LimitedBox(maxHeight: maxHeight, child: gridView);
return Obx(() => stateGlobal.isPortrait.isFalse
? gridView(false)
: LimitedBox(maxHeight: maxHeight, child: gridView(true)));
});
}
@@ -359,7 +359,6 @@ class _AddressBookState extends State<AddressBook> {
alignment: Alignment.topLeft,
child: AddressBookPeersView(
menuPadding: widget.menuPadding,
getInitPeers: () => gFFI.abModel.currentAbPeers,
)),
);
}
@@ -425,7 +424,8 @@ class _AddressBookState extends State<AddressBook> {
if (canWrite) getEntry(translate("Add ID"), addIdToCurrentAb),
if (canWrite) getEntry(translate("Add Tag"), abAddTag),
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(),
filterMenuItem(),
if (!gFFI.abModel.legacyMode.value && canWrite)
@@ -508,20 +508,21 @@ class _AddressBookState extends State<AddressBook> {
double marginBottom = 4;
row({required Widget lable, required Widget input}) {
return Row(
children: [
!isMobile
? ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100),
child: lable.marginOnly(right: 10))
: SizedBox.shrink(),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 200),
child: input),
),
],
).marginOnly(bottom: !isMobile ? 8 : 0);
makeChild(bool isPortrait) => Row(
children: [
!isPortrait
? ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100),
child: lable.marginOnly(right: 10))
: SizedBox.shrink(),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 200),
child: input),
),
],
).marginOnly(bottom: !isPortrait ? 8 : 0);
return Obx(() => makeChild(stateGlobal.isPortrait.isTrue));
}
return CustomAlertDialog(
@@ -544,23 +545,28 @@ class _AddressBookState extends State<AddressBook> {
),
],
),
input: TextField(
controller: idController,
inputFormatters: [IDTextInputFormatter()],
decoration: InputDecoration(
labelText: !isMobile ? null : translate('ID'),
errorText: errorMsg,
errorMaxLines: 5),
)),
input: Obx(() => TextField(
controller: idController,
inputFormatters: [IDTextInputFormatter()],
decoration: InputDecoration(
labelText: stateGlobal.isPortrait.isFalse
? null
: translate('ID'),
errorText: errorMsg,
errorMaxLines: 5),
))),
row(
lable: Text(
translate('Alias'),
style: style,
),
input: TextField(
controller: aliasController,
decoration: InputDecoration(
labelText: !isMobile ? null : translate('Alias'),
input: Obx(() => TextField(
controller: aliasController,
decoration: InputDecoration(
labelText: stateGlobal.isPortrait.isFalse
? null
: translate('Alias'),
),
)),
),
if (isCurrentAbShared)
@@ -569,22 +575,26 @@ class _AddressBookState extends State<AddressBook> {
translate('Password'),
style: style,
),
input: TextField(
controller: passwordController,
obscureText: !passwordVisible,
decoration: InputDecoration(
labelText: !isMobile ? null : translate('Password'),
suffixIcon: IconButton(
icon: Icon(
passwordVisible
? Icons.visibility
: Icons.visibility_off,
color: MyTheme.lightTheme.primaryColor),
onPressed: () {
setState(() {
passwordVisible = !passwordVisible;
});
},
input: Obx(
() => TextField(
controller: passwordController,
obscureText: !passwordVisible,
decoration: InputDecoration(
labelText: stateGlobal.isPortrait.isFalse
? null
: translate('Password'),
suffixIcon: IconButton(
icon: Icon(
passwordVisible
? Icons.visibility
: Icons.visibility_off,
color: MyTheme.lightTheme.primaryColor),
onPressed: () {
setState(() {
passwordVisible = !passwordVisible;
});
},
),
),
),
)),

View File

@@ -2,22 +2,39 @@ import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
const _kWindowsSystemSound = 'System Sound';
typedef AudioINputSetDevice = void Function(String device);
typedef AudioInputBuilder = Widget Function(
List<String> devices, String currentDevice, AudioINputSetDevice setDevice);
class AudioInput extends StatelessWidget {
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() {
if (isWindows) return translate('System Sound');
return '';
}
static Future<String> getValue() async {
String device = await bind.mainGetOption(key: 'audio-input');
static Future<String> getAudioInput(bool isCm, bool isVoiceCall) {
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) {
return device;
} 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 = '';
await bind.mainSetOption(key: 'audio-input', value: device);
if (isVoiceCall) {
await bind.setVoiceCallInputDevice(isCm: isCm, device: device);
} else {
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();
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};
}
@override
Widget build(BuildContext context) {
return futureBuilder(
future: getDevicesInfo(),
future: getDevicesInfo(isCm, isVoiceCall),
hasData: (data) {
String currentDevice = data['current'];
List<String> devices = data['devices'] as List<String>;
if (devices.isEmpty) {
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))
.toList();
return Tooltip(
message: isMobile
message: !(isDesktop || isWebDesktop)
? ''
: widget.peer.tags.isNotEmpty
? '${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');
@override
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
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:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:qr_flutter/qr_flutter.dart';
@@ -218,50 +220,53 @@ void changeWhiteList({Function()? callback}) async {
),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
if (!isOptFixed)dialogButton("Clear", onPressed: () async {
await bind.mainSetOption(
key: kOptionWhitelist, value: defaultOptionWhitelist);
callback?.call();
close();
}, isOutline: true),
if (!isOptFixed) dialogButton(
"OK",
onPressed: () async {
setState(() {
msg = "";
isInProgress = true;
});
newWhiteListField = controller.text.trim();
var newWhiteList = "";
if (newWhiteListField.isEmpty) {
// pass
} else {
final ips = newWhiteListField.trim().split(RegExp(r"[\s,;\n]+"));
// test ip
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}$");
final ipv6Match = RegExp(
r"^(((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7})(\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}$");
for (final ip in ips) {
if (!ipMatch.hasMatch(ip) && !ipv6Match.hasMatch(ip)) {
msg = "${translate("Invalid IP")} $ip";
setState(() {
isInProgress = false;
});
return;
}
}
newWhiteList = ips.join(',');
}
if (newWhiteList.trim().isEmpty) {
newWhiteList = defaultOptionWhitelist;
}
if (!isOptFixed)
dialogButton("Clear", onPressed: () async {
await bind.mainSetOption(
key: kOptionWhitelist, value: newWhiteList);
key: kOptionWhitelist, value: defaultOptionWhitelist);
callback?.call();
close();
},
),
}, isOutline: true),
if (!isOptFixed)
dialogButton(
"OK",
onPressed: () async {
setState(() {
msg = "";
isInProgress = true;
});
newWhiteListField = controller.text.trim();
var newWhiteList = "";
if (newWhiteListField.isEmpty) {
// pass
} else {
final ips =
newWhiteListField.trim().split(RegExp(r"[\s,;\n]+"));
// test ip
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}$");
final ipv6Match = RegExp(
r"^(((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7})(\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}$");
for (final ip in ips) {
if (!ipMatch.hasMatch(ip) && !ipv6Match.hasMatch(ip)) {
msg = "${translate("Invalid IP")} $ip";
setState(() {
isInProgress = false;
});
return;
}
}
newWhiteList = ips.join(',');
}
if (newWhiteList.trim().isEmpty) {
newWhiteList = defaultOptionWhitelist;
}
await bind.mainSetOption(
key: kOptionWhitelist, value: newWhiteList);
callback?.call();
close();
},
),
],
onCancel: close,
);
@@ -376,6 +381,7 @@ class DialogTextField extends StatelessWidget {
final FocusNode? focusNode;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final int? maxLength;
static const kUsernameTitle = 'Username';
static const kUsernameIcon = Icon(Icons.account_circle_outlined);
@@ -393,6 +399,7 @@ class DialogTextField extends StatelessWidget {
this.hintText,
this.keyboardType,
this.inputFormatters,
this.maxLength,
required this.title,
required this.controller})
: super(key: key);
@@ -419,6 +426,7 @@ class DialogTextField extends StatelessWidget {
obscureText: obscureText,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLength: maxLength,
),
),
],
@@ -675,6 +683,8 @@ class PasswordWidget extends StatefulWidget {
this.reRequestFocus = false,
this.hintText,
this.errorText,
this.title,
this.maxLength,
}) : super(key: key);
final TextEditingController controller;
@@ -682,6 +692,8 @@ class PasswordWidget extends StatefulWidget {
final bool reRequestFocus;
final String? hintText;
final String? errorText;
final String? title;
final int? maxLength;
@override
State<PasswordWidget> createState() => _PasswordWidgetState();
@@ -725,7 +737,7 @@ class _PasswordWidgetState extends State<PasswordWidget> {
@override
Widget build(BuildContext context) {
return DialogTextField(
title: translate(DialogTextField.kPasswordTitle),
title: translate(widget.title ?? DialogTextField.kPasswordTitle),
hintText: translate(widget.hintText ?? 'Enter your password'),
controller: widget.controller,
prefixIcon: DialogTextField.kPasswordIcon,
@@ -744,6 +756,7 @@ class _PasswordWidgetState extends State<PasswordWidget> {
obscureText: !_passwordVisible,
errorText: widget.errorText,
focusNode: _focusNode,
maxLength: widget.maxLength,
);
}
}
@@ -1117,7 +1130,7 @@ void showRequestElevationDialog(
errorText: errPwd.isEmpty ? null : errPwd.value,
),
],
).marginOnly(left: (isDesktop || isWebDesktop) ? 35 : 0),
).marginOnly(left: stateGlobal.isPortrait.isFalse ? 35 : 0),
).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 {
if (bind.mainHasValid2FaSync()) {
await bind.mainSetOption(key: "2fa", value: "");
await bind.mainClearTrustedDevices();
callback?.call();
return;
}
@@ -1832,6 +1906,7 @@ void enter2FaDialog(
SessionID sessionId, OverlayDialogManager dialogManager) async {
final controller = TextEditingController();
final RxBool submitReady = false.obs;
final RxBool trustThisDevice = false.obs;
dialogManager.dismissAll();
dialogManager.show((setState, close, context) {
@@ -1841,7 +1916,7 @@ void enter2FaDialog(
}
submit() {
gFFI.send2FA(sessionId, controller.text.trim());
gFFI.send2FA(sessionId, controller.text.trim(), trustThisDevice.value);
close();
dialogManager.showLoading(translate('Logging in...'),
onCancel: closeConnection);
@@ -1855,9 +1930,27 @@ void enter2FaDialog(
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(
title: Text(translate('enter-2fa-title')),
content: codeField,
content: Column(
children: [
codeField,
if (bind.sessionGetEnableTrustedDevices(sessionId: sessionId))
trustField,
],
),
actions: [
dialogButton('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

@@ -142,11 +142,6 @@ class _WidgetOPState extends State<WidgetOP> {
String _failedMsg = '';
String _url = '';
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();

View File

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

View File

@@ -353,7 +353,7 @@ class Draggable extends StatefulWidget {
final Widget Function(BuildContext, GestureDragUpdateCallback) builder;
@override
State<StatefulWidget> createState() => _DraggableState();
State<StatefulWidget> createState() => _DraggableState(chatModel);
}
class _DraggableState extends State<Draggable> {
@@ -362,10 +362,8 @@ class _DraggableState extends State<Draggable> {
double _saveHeight = 0;
double _lastBottomHeight = 0;
@override
void initState() {
super.initState();
_chatModel = widget.chatModel;
_DraggableState(ChatModel? chatModel) {
_chatModel = chatModel;
}
get position => widget.position.pos;
@@ -467,7 +465,8 @@ class IOSDraggable extends StatefulWidget {
final Widget Function(BuildContext) builder;
@override
IOSDraggableState createState() => IOSDraggableState();
IOSDraggableState createState() =>
IOSDraggableState(chatModel, width, height);
}
class IOSDraggableState extends State<IOSDraggable> {
@@ -478,15 +477,13 @@ class IOSDraggableState extends State<IOSDraggable> {
double _saveHeight = 0;
double _lastBottomHeight = 0;
@override
void initState() {
super.initState();
_chatModel = widget.chatModel;
_width = widget.width;
_height = widget.height;
IOSDraggableState(ChatModel? chatModel, double w, double h) {
_chatModel = chatModel;
_width = w;
_height = h;
}
get position => widget.position;
DraggableKeyPosition get position => widget.position;
checkKeyboard() {
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
@@ -494,13 +491,13 @@ class IOSDraggableState extends State<IOSDraggable> {
// save
if (!_keyboardVisible && currentVisible) {
_saveHeight = position.value.dy;
_saveHeight = position.pos.dy;
}
// reset
if (_lastBottomHeight > 0 && bottomHeight == 0) {
setState(() {
position.value = Offset(position.value.dx, _saveHeight);
position.update(Offset(position.pos.dx, _saveHeight));
});
}
@@ -508,10 +505,10 @@ class IOSDraggableState extends State<IOSDraggable> {
if (_keyboardVisible && currentVisible) {
final sumHeight = bottomHeight + _height;
final contextHeight = MediaQuery.of(context).size.height;
if (sumHeight + position.value.dy > contextHeight) {
if (sumHeight + position.pos.dy > contextHeight) {
final y = contextHeight - sumHeight;
setState(() {
position.value = Offset(position.value.dx, y);
position.update(Offset(position.pos.dx, y));
});
}
}
@@ -526,14 +523,14 @@ class IOSDraggableState extends State<IOSDraggable> {
return Stack(
children: [
Positioned(
left: position.value.dx,
top: position.value.dy,
left: position.pos.dx,
top: position.pos.dy,
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
position.value += details.delta;
position.update(position.pos + details.delta);
});
_chatModel?.setChatWindowPosition(position.value);
_chatModel?.setChatWindowPosition(position.pos);
},
child: Material(
child: Container(

View File

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

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

View File

@@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/consts.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:provider/provider.dart';
import 'package:visibility_detector/visibility_detector.dart';
@@ -41,14 +43,26 @@ class LoadEvent {
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
final peerSearchText = "".obs;
/// 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
final obslist = [peerSearchText, peerSort].obs;
RxList<RxString> get obslist => [peerSearchText, peerSort].obs;
final peerSearchTextController =
TextEditingController(text: peerSearchText.value);
@@ -70,7 +84,8 @@ class _PeersView extends StatefulWidget {
}
/// 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;
final HashMap<String, String> _emptyMessages = HashMap.from({
LoadEvent.recent: 'empty_recent_tip',
@@ -82,9 +97,11 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
final _curPeers = <String>{};
var _lastChangeTime = DateTime.now();
var _lastQueryPeers = <String>{};
var _lastQueryTime = DateTime.now().add(const Duration(seconds: 30));
var _lastQueryTime = DateTime.now();
var _lastWindowRestoreTime = DateTime.now();
var _queryCount = 0;
var _exit = false;
bool _isActive = true;
final _scrollController = ScrollController();
@@ -95,12 +112,14 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
@override
void initState() {
windowManager.addListener(this);
WidgetsBinding.instance.addObserver(this);
super.initState();
}
@override
void dispose() {
windowManager.removeListener(this);
WidgetsBinding.instance.removeObserver(this);
_exit = true;
super.dispose();
}
@@ -108,17 +127,61 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
@override
void onWindowFocus() {
_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
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
Widget build(BuildContext context) {
return ChangeNotifierProvider<Peers>(
create: (context) => widget.peers,
// We should avoid too many rebuilds. MacOS(m1, 14.6.1) on Flutter 3.19.6.
// 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) {
if (peers.peers.isEmpty) {
gFFI.peerTabModel.setCurrentTabCachedPeers([]);
@@ -172,7 +235,7 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
var peers = snapshot.data!;
if (peers.length > 1000) peers = peers.sublist(0, 1000);
gFFI.peerTabModel.setCurrentTabCachedPeers(peers);
buildOnePeer(Peer peer) {
buildOnePeer(Peer peer, bool isPortrait) {
final visibilityChild = VisibilityDetector(
key: ValueKey(_cardId(peer.id)),
onVisibilityChanged: onVisibilityChanged,
@@ -184,7 +247,7 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
// No need to listen the currentTab change event.
// Because the currentTab change event will trigger the peers change event,
// and the peers change event will trigger _buildPeersView().
return (isDesktop || isWebDesktop)
return !isPortrait
? Obx(() => peerCardUiType.value == PeerUiType.list
? Container(height: 45, child: visibilityChild)
: peerCardUiType.value == PeerUiType.grid
@@ -195,44 +258,45 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
: Container(child: visibilityChild);
}
final Widget child;
if (isMobile) {
child = ListView.builder(
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index]).marginOnly(
top: index == 0 ? 0 : space / 2, bottom: space / 2);
},
);
} else {
child = Obx(() => peerCardUiType.value == PeerUiType.list
? DesktopScrollWrapper(
scrollController: _scrollController,
child: ListView.builder(
controller: _scrollController,
physics: DraggableNeverScrollableScrollPhysics(),
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index]).marginOnly(
right: space,
top: index == 0 ? 0 : space / 2,
bottom: space / 2);
}),
)
: DesktopScrollWrapper(
scrollController: _scrollController,
child: DynamicGridView.builder(
controller: _scrollController,
physics: DraggableNeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithWrapping(
mainAxisSpacing: space / 2,
crossAxisSpacing: space),
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index]);
}),
));
}
// We should avoid too many rebuilds. Win10(Some machines) on Flutter 3.19.6.
// Continious rebuilds of `ListView.builder` will cause memory leak.
// Simple demo can reproduce this issue.
final Widget child = Obx(() => stateGlobal.isPortrait.isTrue
? ListView.builder(
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index], true).marginOnly(
top: index == 0 ? 0 : space / 2, bottom: space / 2);
},
)
: peerCardUiType.value == PeerUiType.list
? DesktopScrollWrapper(
scrollController: _scrollController,
child: ListView.builder(
controller: _scrollController,
physics: DraggableNeverScrollableScrollPhysics(),
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index], false)
.marginOnly(
right: space,
top: index == 0 ? 0 : space / 2,
bottom: space / 2);
}),
)
: DesktopScrollWrapper(
scrollController: _scrollController,
child: DynamicGridView.builder(
controller: _scrollController,
physics: DraggableNeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithWrapping(
mainAxisSpacing: space / 2,
crossAxisSpacing: space),
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index], false);
}),
));
if (updateEvent == UpdateEvent.load) {
_curPeers.clear();
@@ -253,10 +317,14 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
return body;
}
final _queryInterval = const Duration(seconds: 20);
var _queryInterval = const Duration(seconds: 20);
void _startCheckOnlines() {
() async {
final p = await bind.mainIsUsingPublicServer();
if (!p) {
_queryInterval = const Duration(seconds: 6);
}
while (!_exit) {
final now = DateTime.now();
if (!setEquals(_curPeers, _lastQueryPeers)) {
@@ -264,7 +332,12 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
_queryOnlines(false);
}
} 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 (_curPeers.isNotEmpty) {
bind.queryOnlines(ids: _curPeers.toList(growable: false));
@@ -282,14 +355,14 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
_queryOnlines(bool isLoadEvent) {
if (_curPeers.isNotEmpty) {
bind.queryOnlines(ids: _curPeers.toList(growable: false));
_lastQueryPeers = {..._curPeers};
if (isLoadEvent) {
_lastChangeTime = DateTime.now();
} else {
_lastQueryTime = DateTime.now().subtract(_queryInterval);
}
_queryCount = 0;
}
_lastQueryPeers = {..._curPeers};
if (isLoadEvent) {
_lastChangeTime = DateTime.now();
} else {
_lastQueryTime = DateTime.now().subtract(_queryInterval);
}
}
Future<List<Peer>>? matchPeers(
@@ -345,28 +418,39 @@ class _PeersViewState extends State<_PeersView> with WindowListener {
}
abstract class BasePeersView extends StatelessWidget {
final String name;
final String loadEvent;
final PeerTabIndex peerTabIndex;
final PeerFilter? peerFilter;
final PeerCardBuilder peerCardBuilder;
final GetInitPeers? getInitPeers;
const BasePeersView({
Key? key,
required this.name,
required this.loadEvent,
required this.peerTabIndex,
this.peerFilter,
required this.peerCardBuilder,
required this.getInitPeers,
}) : super(key: key);
@override
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(
peers:
Peers(name: name, loadEvent: loadEvent, getInitPeers: getInitPeers),
peerFilter: peerFilter,
peerCardBuilder: peerCardBuilder);
peers: peers, peerFilter: peerFilter, peerCardBuilder: peerCardBuilder);
}
}
@@ -375,13 +459,11 @@ class RecentPeersView extends BasePeersView {
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
name: 'recent peer',
loadEvent: LoadEvent.recent,
peerTabIndex: PeerTabIndex.recent,
peerCardBuilder: (Peer peer) => RecentPeerCard(
peer: peer,
menuPadding: menuPadding,
),
getInitPeers: null,
);
@override
@@ -397,13 +479,11 @@ class FavoritePeersView extends BasePeersView {
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
name: 'favorite peer',
loadEvent: LoadEvent.favorite,
peerTabIndex: PeerTabIndex.fav,
peerCardBuilder: (Peer peer) => FavoritePeerCard(
peer: peer,
menuPadding: menuPadding,
),
getInitPeers: null,
);
@override
@@ -419,13 +499,11 @@ class DiscoveredPeersView extends BasePeersView {
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
name: 'discovered peer',
loadEvent: LoadEvent.lan,
peerTabIndex: PeerTabIndex.lan,
peerCardBuilder: (Peer peer) => DiscoveredPeerCard(
peer: peer,
menuPadding: menuPadding,
),
getInitPeers: null,
);
@override
@@ -438,21 +516,16 @@ class DiscoveredPeersView extends BasePeersView {
class AddressBookPeersView extends BasePeersView {
AddressBookPeersView(
{Key? key,
EdgeInsets? menuPadding,
ScrollController? scrollController,
required GetInitPeers getInitPeers})
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
name: 'address book peer',
loadEvent: LoadEvent.addressBook,
peerTabIndex: PeerTabIndex.ab,
peerFilter: (Peer peer) =>
_hitTag(gFFI.abModel.selectedTags, peer.tags),
peerCardBuilder: (Peer peer) => AddressBookPeerCard(
peer: peer,
menuPadding: menuPadding,
),
getInitPeers: getInitPeers,
);
static bool _hitTag(List<dynamic> selectedTags, List<dynamic> idents) {
@@ -479,20 +552,15 @@ class AddressBookPeersView extends BasePeersView {
class MyGroupPeerView extends BasePeersView {
MyGroupPeerView(
{Key? key,
EdgeInsets? menuPadding,
ScrollController? scrollController,
required GetInitPeers getInitPeers})
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
name: 'group peer',
loadEvent: LoadEvent.group,
peerTabIndex: PeerTabIndex.group,
peerFilter: filter,
peerCardBuilder: (Peer peer) => MyGroupPeerCard(
peer: peer,
menuPadding: menuPadding,
),
getInitPeers: getInitPeers,
);
static bool filter(Peer peer) {

View File

@@ -27,6 +27,10 @@ class RawKeyFocusScope extends StatelessWidget {
@override
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(
autofocus: true,
child: Focus(
@@ -34,8 +38,14 @@ class RawKeyFocusScope extends StatelessWidget {
canRequestFocus: true,
focusNode: focusNode,
onFocusChange: onFocusChange,
onKey: (FocusNode data, RawKeyEvent e) =>
inputModel.handleRawKeyEvent(e),
onKey: useRawKeyEvents
? (FocusNode data, RawKeyEvent event) =>
inputModel.handleRawKeyEvent(event)
: null,
onKeyEvent: useRawKeyEvents
? null
: (FocusNode node, KeyEvent event) =>
inputModel.handleKeyEvent(event),
child: child));
}
}
@@ -233,7 +243,7 @@ class _RawTouchGestureDetectorRegionState
if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) {
return;
}
if (isDesktop) {
if (isDesktop || isWebDesktop) {
ffi.cursorModel.trySetRemoteWindowCoords();
}
// Workaround for the issue that the first pan event is sent a long time after the start event.
@@ -275,7 +285,7 @@ class _RawTouchGestureDetectorRegionState
if (lastDeviceKind != PointerDeviceKind.touch) {
return;
}
if (isDesktop) {
if (isDesktop || isWebDesktop) {
ffi.cursorModel.clearRemoteWindowCoords();
}
inputModel.sendMouse('up', MouseButtons.left);

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/widgets/dialog.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/platform_model.dart';
import 'package:get/get.dart';
@@ -22,6 +23,20 @@ class TTextMenu {
required this.onPressed,
this.trailingIcon,
this.divider = false});
Widget getChild() {
if (trailingIcon != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
child,
trailingIcon!,
],
);
} else {
return child;
}
}
}
class TRadioMenu<T> {
@@ -115,12 +130,9 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// paste
if (isMobile &&
pi.platform != kPeerPlatformAndroid &&
perms['keyboard'] != false &&
perms['clipboard'] != false) {
if (pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false) {
v.add(TTextMenu(
child: Text(translate('Paste')),
child: Text(translate('Send clipboard keystrokes')),
onPressed: () async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
@@ -636,6 +648,18 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
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;
}
@@ -776,3 +800,106 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
}
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 kPeerPlatformMacOS = "Mac OS";
const String kPeerPlatformAndroid = "Android";
const String kPeerPlatformWebDesktop = "WebDesktop";
const double kScrollbarThickness = 12.0;
@@ -135,6 +136,18 @@ const String kOptionAllowLinuxHeadless = "allow-linux-headless";
const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
const String kOptionStopService = "stop-service";
const String kOptionDirectxCapture = "enable-directx-capture";
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
const String 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";
@@ -155,6 +168,8 @@ const int kWindowMainId = 0;
const String kPointerEventKindTouch = "touch";
const String kPointerEventKindMouse = "mouse";
const String kKeyFlutterKey = "flutter_key";
const String kKeyShowDisplaysAsIndividualWindows =
'displays_as_individual_windows';
const String kKeyUseAllMyDisplaysForTheRemoteSession =
@@ -186,7 +201,7 @@ const double kMinFps = 5;
const double kDefaultFps = 30;
const double kMaxFps = 120;
const double kMinQuality = 10;
const double kMinQuality = 5;
const double kDefaultQuality = 50;
const double kMaxQuality = 100;
const double kMaxMoreQuality = 2000;
@@ -227,9 +242,9 @@ const kDefaultScrollDuration = Duration(milliseconds: 50);
const kDefaultMouseWheelThrottleDuration = Duration(milliseconds: 50);
const kFullScreenEdgeSize = 0.0;
const kMaximizeEdgeSize = 0.0;
// Do not use kWindowEdgeSize directly. Use `windowEdgeSize` in `common.dart` instead.
final kWindowEdgeSize = isWindows ? 1.0 : 5.0;
final kWindowBorderWidth = 1.0;
// Do not use kWindowResizeEdgeSize directly. Use `windowResizeEdgeSize` in `common.dart` instead.
const kWindowResizeEdgeSize = 5.0;
const kWindowBorderWidth = 1.0;
const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0);
const kFrameBorderRadius = 12.0;
const kFrameClipRRectBorderRadius = 12.0;
@@ -555,3 +570,5 @@ enum WindowsTarget {
extension WindowsTargetExt on int {
WindowsTarget get windowsVersion => getWindowsTarget(this);
}
const kCheckSoftwareUpdateFinish = 'check_software_update_finish';

View File

@@ -3,8 +3,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:auto_size_text/auto_size_text.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/models/state_model.dart';
import 'package:get/get.dart';
@@ -169,16 +169,12 @@ class _OnlineStatusWidgetState extends State<OnlineStatusWidget> {
final status =
jsonDecode(await bind.mainGetConnectStatus()) as Map<String, dynamic>;
final statusNum = status['status_num'] as int;
final preStatus = stateGlobal.svcStatus.value;
if (statusNum == 0) {
stateGlobal.svcStatus.value = SvcStatus.connecting;
} else if (statusNum == -1) {
stateGlobal.svcStatus.value = SvcStatus.notReady;
} else if (statusNum == 1) {
stateGlobal.svcStatus.value = SvcStatus.ready;
if (preStatus != SvcStatus.ready) {
gFFI.userModel.refreshCurrentUser();
}
} else {
stateGlobal.svcStatus.value = SvcStatus.notReady;
}
@@ -212,14 +208,14 @@ class _ConnectionPageState extends State<ConnectionPage>
void initState() {
super.initState();
if (_idController.text.isEmpty) {
() async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId();
if (lastRemoteId != _idController.id) {
setState(() {
_idController.id = lastRemoteId;
});
}
}();
});
}
Get.put<IDTextEditingController>(_idController);
windowManager.addListener(this);
@@ -261,8 +257,9 @@ class _ConnectionPageState extends State<ConnectionPage>
@override
void onWindowLeaveFullScreen() {
// Restore edge border to default edge size.
stateGlobal.resizeEdgeSize.value =
stateGlobal.isMaximized.isTrue ? kMaximizeEdgeSize : windowEdgeSize;
stateGlobal.resizeEdgeSize.value = stateGlobal.isMaximized.isTrue
? kMaximizeEdgeSize
: windowResizeEdgeSize;
}
@override
@@ -326,36 +323,7 @@ class _ConnectionPageState extends State<ConnectionPage>
child: Ink(
child: Column(
children: [
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: 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),
getConnectionPageTitle(context, false).marginOnly(bottom: 15),
Row(
children: [
Expanded(

View File

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

View File

@@ -61,9 +61,14 @@ class DesktopSettingPage extends StatefulWidget {
final SettingsTabKey initialTabkey;
static final List<SettingsTabKey> tabKeys = [
SettingsTabKey.general,
if (!bind.isOutgoingOnly() && !bind.isDisableSettings())
if (!isWeb &&
!bind.isOutgoingOnly() &&
!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y')
SettingsTabKey.safety,
if (!bind.isDisableSettings()) SettingsTabKey.network,
if (!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y')
SettingsTabKey.network,
if (!bind.isIncomingOnly()) SettingsTabKey.display,
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
SettingsTabKey.plugin,
@@ -74,7 +79,8 @@ class DesktopSettingPage extends StatefulWidget {
DesktopSettingPage({Key? key, required this.initialTabkey}) : super(key: key);
@override
State<DesktopSettingPage> createState() => _DesktopSettingPageState();
State<DesktopSettingPage> createState() =>
_DesktopSettingPageState(initialTabkey);
static void switch2page(SettingsTabKey page) {
try {
@@ -84,8 +90,10 @@ class DesktopSettingPage extends StatefulWidget {
}
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) {
DesktopTabPage.onAddSetting(initialPage: page);
PageController controller = Get.find(tag: _kSettingPageControllerTag);
Rx<SettingsTabKey> selected = Get.find(tag: _kSettingPageTabKeyTag);
PageController controller =
Get.find<PageController>(tag: _kSettingPageControllerTag);
Rx<SettingsTabKey> selected =
Get.find<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
selected.value = page;
controller.jumpToPage(index);
} else {
@@ -105,10 +113,8 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
var initialIndex = DesktopSettingPage.tabKeys.indexOf(widget.initialTabkey);
_DesktopSettingPageState(SettingsTabKey initialTabkey) {
var initialIndex = DesktopSettingPage.tabKeys.indexOf(initialTabkey);
if (initialIndex == -1) {
initialIndex = 0;
}
@@ -171,16 +177,32 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
}
List<Widget> _children() {
final children = [
_General(),
if (!bind.isOutgoingOnly() && !bind.isDisableSettings()) _Safety(),
if (!bind.isDisableSettings()) _Network(),
if (!bind.isIncomingOnly()) _Display(),
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
_Plugin(),
if (!bind.isDisableAccount()) _Account(),
_About(),
];
final children = List<Widget>.empty(growable: true);
for (final tab in DesktopSettingPage.tabKeys) {
switch (tab) {
case SettingsTabKey.general:
children.add(const _General());
break;
case SettingsTabKey.safety:
children.add(const _Safety());
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;
}
@@ -195,7 +217,7 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
width: _kTabWidth,
child: Column(
children: [
_header(),
_header(context),
Flexible(child: _listView(tabs: _settingTabs())),
],
),
@@ -218,21 +240,40 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
);
}
Widget _header() {
Widget _header(BuildContext context) {
final settingsText = Text(
translate('Settings'),
textAlign: TextAlign.left,
style: const TextStyle(
color: _accentColor,
fontSize: _kTitleFontSize,
fontWeight: FontWeight.w400,
),
);
return Row(
children: [
SizedBox(
height: 62,
child: Text(
translate('Settings'),
textAlign: TextAlign.left,
style: const TextStyle(
color: _accentColor,
fontSize: _kTitleFontSize,
fontWeight: FontWeight.w400,
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, top: 10),
).marginOnly(left: 20),
if (!isWeb)
SizedBox(
height: 62,
child: settingsText,
).marginOnly(left: 20, top: 10),
const Spacer(),
],
);
@@ -301,7 +342,8 @@ class _General extends StatefulWidget {
}
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;
@override
@@ -313,13 +355,13 @@ class _GeneralState extends State<_General> {
physics: DraggableNeverScrollableScrollPhysics(),
controller: scrollController,
children: [
service(),
if (!isWeb) service(),
theme(),
_Card(title: 'Language', children: [language()]),
hwcodec(),
audio(context),
record(context),
WaylandCard(),
if (!isWeb) hwcodec(),
if (!isWeb) audio(context),
if (!isWeb) record(context),
if (!isWeb) WaylandCard(),
other()
],
).marginOnly(bottom: _kListViewBottomMargin));
@@ -327,8 +369,8 @@ class _GeneralState extends State<_General> {
Widget theme() {
final current = MyTheme.getThemeModePreference().toShortString();
onChanged(String value) {
MyTheme.changeDarkMode(MyTheme.themeModeFromString(value));
onChanged(String value) async {
await MyTheme.changeDarkMode(MyTheme.themeModeFromString(value));
setState(() {});
}
@@ -373,13 +415,13 @@ class _GeneralState extends State<_General> {
Widget other() {
final children = <Widget>[
if (!bind.isIncomingOnly())
if (!isWeb && !bind.isIncomingOnly())
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
kOptionEnableConfirmClosingTabs,
isServer: false),
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
wallpaper(),
if (!bind.isIncomingOnly()) ...[
if (!isWeb) wallpaper(),
if (!isWeb && !bind.isIncomingOnly()) ...[
_OptionCheckBox(
context,
'Open connection in new tab',
@@ -396,18 +438,19 @@ class _GeneralState extends State<_General> {
kOptionAllowAlwaysSoftwareRender,
),
),
Tooltip(
message: translate('texture_render_tip'),
child: _OptionCheckBox(
context,
"Use texture rendering",
kOptionTextureRender,
optGetter: bind.mainGetUseTextureRender,
optSetter: (k, v) async =>
await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'),
if (!isWeb)
Tooltip(
message: translate('texture_render_tip'),
child: _OptionCheckBox(
context,
"Use texture rendering",
kOptionTextureRender,
optGetter: bind.mainGetUseTextureRender,
optSetter: (k, v) async =>
await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'),
),
),
),
if (!bind.isCustomClient())
if (!isWeb && !bind.isCustomClient())
_OptionCheckBox(
context,
'Check for software update on startup',
@@ -422,7 +465,7 @@ class _GeneralState extends State<_General> {
)
],
];
if (bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
children.add(_OptionCheckBox(
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
}
@@ -493,18 +536,20 @@ class _GeneralState extends State<_General> {
return const Offstage();
}
return AudioInput(builder: (devices, currentDevice, setDevice) {
return _Card(title: 'Audio Input Device', children: [
...devices.map((device) => _Radio<String>(context,
value: device,
groupValue: currentDevice,
autoNewLine: false,
label: device, onChanged: (value) {
setDevice(value);
setState(() {});
}))
]);
});
builder(devices, currentDevice, setDevice) {
final child = ComboBox(
keys: devices,
values: devices,
initialKey: currentDevice,
onChanged: (key) async {
setDevice(key);
setState(() {});
},
).marginOnly(left: _kContentHMargin);
return _Card(title: 'Audio Input Device', children: [child]);
}
return AudioInput(builder: builder, isCm: false, isVoiceCall: false);
}
Widget record(BuildContext context) {
@@ -618,8 +663,9 @@ class _GeneralState extends State<_General> {
initialKey: currentKey,
onChanged: (key) async {
await bind.mainSetLocalOption(key: kCommConfKeyLang, value: key);
reloadAllWindows();
bind.mainChangeLanguage(lang: key);
if (isWeb) reloadCurrentWindow();
if (!isWeb) reloadAllWindows();
if (!isWeb) bind.mainChangeLanguage(lang: key);
},
enabled: !isOptFixed,
).marginOnly(left: _kContentHMargin);
@@ -679,15 +725,24 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
// Simple temp wrapper for PR check
tmpWrapper() {
RxBool has2fa = bind.mainHasValid2FaSync().obs;
RxBool hasBot = bind.mainHasValidBotSync().obs;
update() async {
has2fa.value = bind.mainHasValid2FaSync();
setState(() {});
}
onChanged(bool? checked) async {
change2fa(callback: update);
if (checked == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-2fa-confirm-tip'), () {
change2fa(callback: update);
});
} else {
change2fa(callback: update);
}
}
return GestureDetector(
final tfa = GestureDetector(
child: InkWell(
child: Obx(() => Row(
children: [
@@ -708,6 +763,77 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
onChanged(!has2fa.value);
},
).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();
@@ -832,12 +958,22 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
label: value,
onChanged: locked
? null
: ((value) {
() async {
: ((value) async {
callback() async {
await model.setVerificationMethod(
passwordKeys[passwordValues.indexOf(value)]);
await model.updatePasswordModel();
}();
}
if (value ==
passwordValues[passwordKeys
.indexOf(kUsePermanentPassword)] &&
(await bind.mainGetPermanentPassword())
.isEmpty) {
setPasswordDialog(notEmptyCallback: callback);
} else {
await callback();
}
}),
))
.toList();
@@ -930,6 +1066,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
'allow-only-conn-window-open',
reverse: false, enabled: enabled),
if (bind.mainIsInstalled()) unlockPin()
]);
}
@@ -1030,12 +1167,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
bool enabled = !locked;
// Simple temp wrapper for PR check
tmpWrapper() {
RxBool hasWhitelist = (bind.mainGetOptionSync(key: kOptionWhitelist) !=
defaultOptionWhitelist)
.obs;
RxBool hasWhitelist = whitelistNotEmpty().obs;
update() async {
hasWhitelist.value = bind.mainGetOptionSync(key: kOptionWhitelist) !=
defaultOptionWhitelist;
hasWhitelist.value = whitelistNotEmpty();
}
onChanged(bool? checked) async {
@@ -1146,7 +1280,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
width: 95,
child: TextField(
controller: controller,
enabled: enabled && !locked && isOptFixed,
enabled: enabled && !locked && !isOptFixed,
onChanged: (_) => applyEnabled.value = true,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(
@@ -1180,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 {
@@ -1192,13 +1360,18 @@ class _Network extends StatefulWidget {
class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
bool locked = bind.mainIsInstalled();
bool locked = !isWeb && bind.mainIsInstalled();
@override
Widget build(BuildContext context) {
super.build(context);
bool enabled = !locked;
final scrollController = ScrollController();
final hideServer =
bind.mainGetBuildinOption(key: kOptionHideServerSetting) == 'Y';
// TODO: support web proxy
final hideProxy =
isWeb || bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y';
return DesktopScrollWrapper(
scrollController: scrollController,
child: ListView(
@@ -1212,11 +1385,12 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
AbsorbPointer(
absorbing: locked,
child: Column(children: [
server(enabled),
_Card(title: 'Proxy', children: [
_Button('Socks5/Http(s) Proxy', changeSocks5Proxy,
enabled: enabled),
]),
if (!hideServer) server(enabled),
if (!hideProxy)
_Card(title: 'Proxy', children: [
_Button('Socks5/Http(s) Proxy', changeSocks5Proxy,
enabled: enabled),
]),
]),
),
]).marginOnly(bottom: _kListViewBottomMargin));
@@ -1277,8 +1451,9 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
children: [
Obx(() => _LabeledTextField(context, 'ID Server', idController,
idErrMsg.value, enabled, secure)),
Obx(() => _LabeledTextField(context, 'Relay Server',
relayController, relayErrMsg.value, enabled, secure)),
if (!isWeb)
Obx(() => _LabeledTextField(context, 'Relay Server',
relayController, relayErrMsg.value, enabled, secure)),
Obx(() => _LabeledTextField(context, 'API Server',
apiController, apiErrMsg.value, enabled, secure)),
_LabeledTextField(
@@ -1317,7 +1492,7 @@ class _DisplayState extends State<_Display> {
scrollStyle(context),
imageQuality(context),
codec(context),
privacyModeImpl(context),
if (!isWeb) privacyModeImpl(context),
other(context),
]).marginOnly(bottom: _kListViewBottomMargin));
}
@@ -1715,7 +1890,7 @@ class _AboutState extends State<_About> {
child: SingleChildScrollView(
controller: scrollController,
physics: DraggableNeverScrollableScrollPhysics(),
child: _Card(title: '${translate('About')} RustDesk', children: [
child: _Card(title: translate('About RustDesk'), children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1728,9 +1903,10 @@ class _AboutState extends State<_About> {
SelectionArea(
child: Text('${translate('Build Date')}: $buildDate')
.marginSymmetric(vertical: 4.0)),
SelectionArea(
child: Text('${translate('Fingerprint')}: $fingerprint')
.marginSymmetric(vertical: 4.0)),
if (!isWeb)
SelectionArea(
child: Text('${translate('Fingerprint')}: $fingerprint')
.marginSymmetric(vertical: 4.0)),
InkWell(
onTap: () {
launchUrlString('https://rustdesk.com/privacy.html');
@@ -2070,9 +2246,14 @@ Widget _lock(
Text(translate(label)).marginOnly(left: 5),
]).marginSymmetric(vertical: 2)),
onPressed: () async {
bool checked = await callMainCheckSuperUserPermission();
if (checked) {
onUnlock();
final unlockPin = bind.mainGetUnlockPin();
if (unlockPin.isEmpty) {
bool checked = await callMainCheckSuperUserPermission();
if (checked) {
onUnlock();
}
} else {
checkUnlockPinDialog(unlockPin, onUnlock);
}
},
).marginSymmetric(horizontal: 2, vertical: 4),
@@ -2249,35 +2430,40 @@ void changeSocks5Proxy() async {
children: [
Row(
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Align(
alignment: Alignment.centerRight,
child: Row(
children: [
Text(
translate('Server'),
).marginOnly(right: 4),
Tooltip(
waitDuration: Duration(milliseconds: 0),
message: translate("default_proxy_tip"),
child: Icon(
Icons.help_outline_outlined,
size: 16,
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.5),
if (!isMobile)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Align(
alignment: Alignment.centerRight,
child: Row(
children: [
Text(
translate('Server'),
).marginOnly(right: 4),
Tooltip(
waitDuration: Duration(milliseconds: 0),
message: translate("default_proxy_tip"),
child: Icon(
Icons.help_outline_outlined,
size: 16,
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.5),
),
),
),
],
)).marginOnly(right: 10),
),
],
)).marginOnly(right: 10),
),
Expanded(
child: TextField(
decoration: InputDecoration(
errorText: proxyMsg.isNotEmpty ? proxyMsg : null,
labelText: isMobile ? translate('Server') : null,
helperText:
isMobile ? translate("default_proxy_tip") : null,
helperMaxLines: isMobile ? 3 : null,
),
controller: proxyController,
autofocus: true,
@@ -2288,15 +2474,19 @@ void changeSocks5Proxy() async {
).marginOnly(bottom: 8),
Row(
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Text(
'${translate("Username")}:',
textAlign: TextAlign.right,
).marginOnly(right: 10)),
if (!isMobile)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Text(
'${translate("Username")}:',
textAlign: TextAlign.right,
).marginOnly(right: 10)),
Expanded(
child: TextField(
controller: userController,
decoration: InputDecoration(
labelText: isMobile ? translate('Username') : null,
),
enabled: !isOptFixed,
),
),
@@ -2304,16 +2494,18 @@ void changeSocks5Proxy() async {
).marginOnly(bottom: 8),
Row(
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Text(
'${translate("Password")}:',
textAlign: TextAlign.right,
).marginOnly(right: 10)),
if (!isMobile)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Text(
'${translate("Password")}:',
textAlign: TextAlign.right,
).marginOnly(right: 10)),
Expanded(
child: Obx(() => TextField(
obscureText: obscure.value,
decoration: InputDecoration(
labelText: isMobile ? translate('Password') : null,
suffixIcon: IconButton(
onPressed: () => obscure.value = !obscure.value,
icon: Icon(obscure.value
@@ -2321,6 +2513,7 @@ void changeSocks5Proxy() async {
: Icons.visibility))),
controller: pwdController,
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:get/get.dart';
import 'package:window_manager/window_manager.dart';
// import 'package:flutter/services.dart';
import '../../common/shared_state.dart';
@@ -20,7 +21,7 @@ class DesktopTabPage extends StatefulWidget {
static void onAddSetting(
{SettingsTabKey initialPage = SettingsTabKey.general}) {
try {
DesktopTabController tabController = Get.find();
DesktopTabController tabController = Get.find<DesktopTabController>();
tabController.add(TabInfo(
key: kTabLabelSettingPage,
label: kTabLabelSettingPage,
@@ -41,21 +42,11 @@ class _DesktopTabPageState extends State<DesktopTabPage>
final tabController = DesktopTabController(tabType: DesktopTabType.main);
final RxBool _block = false.obs;
// bool mouseIn = false;
@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();
WidgetsBinding.instance.addObserver(this);
Get.put<DesktopTabController>(tabController);
_DesktopTabPageState() {
RemoteCountState.init();
Get.put<DesktopTabController>(tabController);
tabController.add(TabInfo(
key: kTabLabelHomePage,
label: kTabLabelHomePage,
@@ -78,8 +69,34 @@ 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
void dispose() {
// HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
WidgetsBinding.instance.removeObserver(this);
Get.delete<DesktopTabController>();
@@ -102,18 +119,15 @@ class _DesktopTabPageState extends State<DesktopTabPage>
isClose: false,
),
),
blockTab: _block,
)));
widget() => MouseRegion(
onEnter: (_) async {
await shouldBeBlocked(_block, canBeBlocked);
},
child: FocusScope(child: tabWidget, canRequestFocus: !_block.value));
return isMacOS || kUseCompatibleUiMode
? Obx(() => widget())
? tabWidget
: Obx(
() => DragToResizeArea(
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
child: widget(),
enableResizeEdges: windowManagerEnableResizeEdges,
child: tabWidget,
),
);
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:extended_text/extended_text.dart';
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
import 'package:percent_indicator/percent_indicator.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:get/get.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 '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
@@ -54,21 +57,21 @@ class FileManagerPage extends StatefulWidget {
required this.id,
required this.password,
required this.isSharedPassword,
required this.tabController,
this.tabController,
this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
final DesktopTabController tabController;
final DesktopTabController? tabController;
@override
State<StatefulWidget> createState() => _FileManagerPageState();
}
class _FileManagerPageState extends State<FileManagerPage>
with AutomaticKeepAliveClientMixin {
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
final _mouseFocusScope = Rx<MouseFocusScope>(MouseFocusScope.none);
final _dropMaskVisible = false.obs; // TODO impl drop mask
@@ -92,13 +95,20 @@ class _FileManagerPageState extends State<FileManagerPage>
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
Get.put(_ffi, tag: 'ft_${widget.id}');
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
if (!isLinux) {
WakelockPlus.enable();
}
if (isWeb) {
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
debugPrint("File manager page init success with id ${widget.id}");
_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
@@ -111,12 +121,21 @@ class _FileManagerPageState extends State<FileManagerPage>
}
Get.delete<FFI>(tag: 'ft_${widget.id}');
});
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
bool get wantKeepAlive => true;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
jobController.jobTable.refresh();
}
}
@override
Widget build(BuildContext context) {
super.build(context);
@@ -126,10 +145,11 @@ class _FileManagerPageState extends State<FileManagerPage>
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Row(
children: [
Flexible(
flex: 3,
child: dropArea(FileManagerView(
model.localController, _ffi, _mouseFocusScope))),
if (!isWeb)
Flexible(
flex: 3,
child: dropArea(FileManagerView(
model.localController, _ffi, _mouseFocusScope))),
Flexible(
flex: 3,
child: dropArea(FileManagerView(
@@ -170,10 +190,31 @@ class _FileManagerPageState extends State<FileManagerPage>
/// transfer status list
/// watch transfer status
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(
controller: ScrollController(),
itemBuilder: (BuildContext context, int index) {
final item = jobs[index];
final status = item.getStatus();
return Padding(
padding: const EdgeInsets.only(bottom: 5),
child: generateCard(
@@ -183,15 +224,8 @@ class _FileManagerPageState extends State<FileManagerPage>
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Transform.rotate(
angle: item.isRemoteToLocal ? pi : 0,
child: SvgPicture.asset("assets/arrow.svg",
colorFilter: svgColor(
Theme.of(context).tabBarTheme.labelColor)),
).paddingOnly(left: 15),
const SizedBox(
width: 16.0,
),
getIcon(item)
.marginSymmetric(horizontal: 10, vertical: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -200,45 +234,28 @@ class _FileManagerPageState extends State<FileManagerPage>
Tooltip(
waitDuration: Duration(milliseconds: 500),
message: item.jobName,
child: Text(
item.fileName,
child: ExtendedText(
item.jobName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).paddingSymmetric(vertical: 10),
),
Text(
'${translate("Total")} ${readableFileSize(item.totalSize.toDouble())}',
style: TextStyle(
fontSize: 12,
color: MyTheme.darkGray,
overflowWidget: TextOverflowWidget(
child: Text("..."),
position: TextOverflowPosition.start),
),
),
Offstage(
offstage: item.state != JobState.inProgress,
child: Text(
'${translate("Speed")} ${readableFileSize(item.speed)}/s',
style: TextStyle(
fontSize: 12,
color: MyTheme.darkGray,
),
),
Tooltip(
waitDuration: Duration(milliseconds: 500),
message: status,
child: Text(status,
style: TextStyle(
fontSize: 12,
color: MyTheme.darkGray,
)).marginOnly(top: 6),
),
Offstage(
offstage: item.state == JobState.inProgress,
child: Text(
translate(
item.display(),
),
style: TextStyle(
fontSize: 12,
color: MyTheme.darkGray,
),
),
),
Offstage(
offstage: item.state != JobState.inProgress,
offstage: item.type != JobType.transfer ||
item.state != JobState.inProgress,
child: LinearPercentIndicator(
padding: EdgeInsets.only(right: 15),
animateFromLastPercent: true,
center: Text(
'${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
@@ -248,7 +265,7 @@ class _FileManagerPageState extends State<FileManagerPage>
progressColor: MyTheme.accent,
backgroundColor: Theme.of(context).hoverColor,
lineHeight: kDesktopFileTransferRowHeight,
).paddingSymmetric(vertical: 15),
).paddingSymmetric(vertical: 8),
),
],
),
@@ -259,6 +276,7 @@ class _FileManagerPageState extends State<FileManagerPage>
Offstage(
offstage: item.state != JobState.paused,
child: MenuButton(
tooltip: translate("Resume"),
onPressed: () {
jobController.resumeJob(item.id);
},
@@ -271,7 +289,7 @@ class _FileManagerPageState extends State<FileManagerPage>
),
),
MenuButton(
padding: EdgeInsets.only(right: 15),
tooltip: translate("Delete"),
child: SvgPicture.asset(
"assets/close.svg",
colorFilter: svgColor(Colors.white),
@@ -284,11 +302,11 @@ class _FileManagerPageState extends State<FileManagerPage>
hoverColor: MyTheme.accent80,
),
],
),
).marginAll(12),
],
),
],
).paddingSymmetric(vertical: 10),
),
),
);
},
@@ -472,6 +490,9 @@ class _FileManagerViewState extends State<FileManagerView> {
}
Widget headTools() {
var uploadButtonTapPosition = RelativeRect.fill;
RxBool isUploadFolder =
(bind.mainGetLocalOption(key: 'upload-folder-button') == 'Y').obs;
return Container(
child: Column(
children: [
@@ -518,6 +539,7 @@ class _FileManagerViewState extends State<FileManagerView> {
Row(
children: [
MenuButton(
tooltip: translate('Back'),
padding: EdgeInsets.only(
right: 3,
),
@@ -537,6 +559,7 @@ class _FileManagerViewState extends State<FileManagerView> {
},
),
MenuButton(
tooltip: translate('Parent directory'),
child: RotatedBox(
quarterTurns: 3,
child: SvgPicture.asset(
@@ -601,6 +624,7 @@ class _FileManagerViewState extends State<FileManagerView> {
switch (_locationStatus.value) {
case LocationStatus.bread:
return MenuButton(
tooltip: translate('Search'),
onPressed: () {
_locationStatus.value = LocationStatus.fileSearchBar;
Future.delayed(
@@ -627,6 +651,7 @@ class _FileManagerViewState extends State<FileManagerView> {
);
case LocationStatus.fileSearchBar:
return MenuButton(
tooltip: translate('Clear'),
onPressed: () {
onSearchText("", isLocal);
_locationStatus.value = LocationStatus.bread;
@@ -642,6 +667,7 @@ class _FileManagerViewState extends State<FileManagerView> {
}
}),
MenuButton(
tooltip: translate('Refresh File'),
padding: EdgeInsets.only(
left: 3,
),
@@ -667,6 +693,7 @@ class _FileManagerViewState extends State<FileManagerView> {
isLocal ? MainAxisAlignment.start : MainAxisAlignment.end,
children: [
MenuButton(
tooltip: translate('Home'),
padding: EdgeInsets.only(
right: 3,
),
@@ -682,11 +709,27 @@ class _FileManagerViewState extends State<FileManagerView> {
hoverColor: Theme.of(context).hoverColor,
),
MenuButton(
tooltip: translate('Create Folder'),
onPressed: () {
final name = TextEditingController();
String? errorText;
_ffi.dialogManager.show((setState, close, context) {
name.addListener(() {
if (errorText != null) {
setState(() {
errorText = null;
});
}
});
submit() {
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.directory.value.path,
name.value.text,
@@ -718,6 +761,7 @@ class _FileManagerViewState extends State<FileManagerView> {
labelText: translate(
"Please enter the folder name",
),
errorText: errorText,
),
controller: name,
autofocus: true,
@@ -751,6 +795,7 @@ class _FileManagerViewState extends State<FileManagerView> {
hoverColor: Theme.of(context).hoverColor,
),
Obx(() => MenuButton(
tooltip: translate('Delete'),
onPressed: SelectedItems.valid(selectedItems.items)
? () async {
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(
style: ButtonStyle(
padding: MaterialStateProperty.all<EdgeInsetsGeometry>(
@@ -803,19 +908,22 @@ class _FileManagerViewState extends State<FileManagerView> {
: Colors.white,
),
)
: RotatedBox(
quarterTurns: 2,
child: SvgPicture.asset(
"assets/arrow.svg",
colorFilter: svgColor(selectedItems.items.isEmpty
? Theme.of(context).brightness ==
Brightness.light
? MyTheme.grayBg
: MyTheme.darkGray
: Colors.white),
alignment: Alignment.bottomRight,
),
),
: isWeb
? Offstage()
: RotatedBox(
quarterTurns: 2,
child: SvgPicture.asset(
"assets/arrow.svg",
colorFilter: svgColor(
selectedItems.items.isEmpty
? Theme.of(context).brightness ==
Brightness.light
? MyTheme.grayBg
: MyTheme.darkGray
: Colors.white),
alignment: Alignment.bottomRight,
),
),
label: isLocal
? SvgPicture.asset(
"assets/arrow.svg",
@@ -827,7 +935,7 @@ class _FileManagerViewState extends State<FileManagerView> {
: Colors.white),
)
: Text(
translate('Receive'),
translate(isWeb ? 'Download' : 'Receive'),
style: TextStyle(
color: selectedItems.items.isEmpty
? Theme.of(context).brightness ==
@@ -882,6 +990,7 @@ class _FileManagerViewState extends State<FileManagerView> {
menuPos = RelativeRect.fromLTRB(x, y, x, y);
},
child: MenuButton(
tooltip: translate('More'),
onPressed: () => mod_menu.showMenu(
context: context,
position: menuPos,
@@ -913,6 +1022,7 @@ class _FileManagerViewState extends State<FileManagerView> {
BuildContext context, ScrollController scrollController) {
final fd = controller.directory.value;
final entries = fd.entries;
Rx<Entry?> rightClickEntry = Rx(null);
return ListSearchActionListener(
node: _keyboardNode,
@@ -971,16 +1081,70 @@ class _FileManagerViewState extends State<FileManagerView> {
final lastModifiedStr = entry.isDrive
? " "
: "${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(
padding: EdgeInsets.symmetric(vertical: 1),
child: Obx(() => Container(
decoration: BoxDecoration(
color: selectedItems.items.contains(entry)
? Theme.of(context).hoverColor
? MyTheme.button
: Theme.of(context).cardColor,
borderRadius: BorderRadius.all(
Radius.circular(5.0),
),
border: rightClickEntry.value == entry
? Border.all(
color: MyTheme.button,
width: 1.0,
)
: null,
),
key: ValueKey(entry.name),
height: kDesktopFileTransferRowHeight,
@@ -1019,22 +1183,19 @@ class _FileManagerViewState extends State<FileManagerView> {
),
Expanded(
child: Text(entry.name.nonBreaking,
style: TextStyle(
color: selectedItems.items
.contains(entry)
? Colors.white
: null),
overflow:
TextOverflow.ellipsis))
]),
)),
),
onTap: () {
final items = selectedItems;
// handle double click
if (_checkDoubleClick(entry)) {
controller.openDirectory(entry.path);
items.clear();
return;
}
_onSelectedChanged(
items, filteredEntries, entry, isLocal);
},
onTap: onTap,
onSecondaryTap: onSecondaryTap,
onSecondaryTapDown: onSecondaryTapDown,
),
SizedBox(
width: 2.0,
@@ -1051,11 +1212,17 @@ class _FileManagerViewState extends State<FileManagerView> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: MyTheme.darkGray,
color: selectedItems.items
.contains(entry)
? Colors.white70
: MyTheme.darkGray,
),
)),
),
),
onTap: onTap,
onSecondaryTap: onSecondaryTap,
onSecondaryTapDown: onSecondaryTapDown,
),
// Divider from header.
SizedBox(
@@ -1071,9 +1238,16 @@ class _FileManagerViewState extends State<FileManagerView> {
sizeStr,
overflow: TextOverflow.ellipsis,
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())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.add(TabInfo(
key: params['id'],
label: params['id'],
@@ -54,8 +55,6 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
void initState() {
super.initState();
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
print(
"[FileTransfer] call ${call.method} with args ${call.arguments} from window $fromWindowId to ${windowId()}");
@@ -97,6 +96,7 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
));
final tabWidget = isLinux
@@ -111,6 +111,7 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
: SubWindowDragToResizeArea(
child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
);
}

View File

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

View File

@@ -63,9 +63,12 @@ class _PortForwardPageState extends State<PortForwardPage>
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
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}");
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);
});
}
@override

View File

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

View File

@@ -45,7 +45,9 @@ class RemotePage extends StatefulWidget {
this.switchUuid,
this.forceRelay,
this.isSharedPassword,
}) : super(key: key);
}) : super(key: key) {
initSharedStates(id);
}
final String id;
final SessionID? sessionId;
@@ -64,7 +66,7 @@ class RemotePage extends StatefulWidget {
@override
State<RemotePage> createState() {
final state = _RemotePageState();
final state = _RemotePageState(id);
_lastState.value = state;
return state;
}
@@ -94,8 +96,11 @@ class _RemotePageState extends State<RemotePage>
SessionID get sessionId => _ffi.sessionId;
_RemotePageState(String id) {
_initStates(id);
}
void _initStates(String id) {
initSharedStates(id);
_zoomCursor = PeerBoolOption.find(id, kOptionZoomCursor);
_showRemoteCursor = ShowRemoteCursorState.find(id);
_keyboardEnabled = KeyboardEnabledState.find(id);
@@ -105,9 +110,8 @@ class _RemotePageState extends State<RemotePage>
@override
void initState() {
super.initState();
_initStates(widget.id);
_ffi = FFI(widget.sessionId);
Get.put(_ffi, tag: widget.id);
Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
showKBLayoutTypeChooserIfNeeded(
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
@@ -135,11 +139,13 @@ class _RemotePageState extends State<RemotePage>
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
_ffi.dialogManager.loadMobileActionsOverlayVisible();
// Session option should be set after models.dart/FFI.start
_showRemoteCursor.value = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'show-remote-cursor');
_zoomCursor.value = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: kOptionZoomCursor);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Session option should be set after models.dart/FFI.start
_showRemoteCursor.value = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'show-remote-cursor');
_zoomCursor.value = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: kOptionZoomCursor);
});
DesktopMultiWindow.addListener(this);
// if (!_isCustomCursorInited) {
// customCursorController.registerNeedUpdateCursorCallback(
@@ -154,7 +160,10 @@ class _RemotePageState extends State<RemotePage>
// }
_blockableOverlayState.applyFfi(_ffi);
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);
});
}
@override
@@ -236,8 +245,10 @@ class _RemotePageState extends State<RemotePage>
super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
_ffi.textureModel.onRemotePageDispose(closeSession);
// ensure we leave this session, this is a double check
_ffi.inputModel.enterOrLeave(false);
if (closeSession) {
// ensure we leave this session, this is a double check
_ffi.inputModel.enterOrLeave(false);
}
DesktopMultiWindow.removeListener(this);
_ffi.dialogManager.hideMobileActionsOverlay();
_ffi.imageModel.disposeImage();
@@ -506,12 +517,13 @@ class _RemotePageState extends State<RemotePage>
];
if (!_ffi.canvasModel.cursorEmbedded) {
paints.add(Obx(() => Offstage(
offstage: _showRemoteCursor.isFalse || _remoteCursorMoved.isFalse,
child: CursorPaint(
id: widget.id,
zoomCursor: _zoomCursor,
))));
paints
.add(Obx(() => _showRemoteCursor.isFalse || _remoteCursorMoved.isFalse
? Offstage()
: CursorPaint(
id: widget.id,
zoomCursor: _zoomCursor,
)));
}
paints.add(
Positioned(
@@ -564,11 +576,6 @@ class _ImagePaintState extends State<ImagePaint> {
RxBool get remoteCursorMoved => widget.remoteCursorMoved;
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context);

View File

@@ -71,7 +71,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final ffi = remotePage.ffi;
bind.setCurSessionId(sessionId: ffi.sessionId);
}
WindowController.fromWindowId(windowId())
WindowController.fromWindowId(params['windowId'])
.setTitle(getWindowNameWithId(id));
UnreadChatCountState.find(id).value = 0;
};
@@ -98,15 +98,14 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
));
_update_remote_count();
}
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
}
@override
void initState() {
super.initState();
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
if (!_isScreenRectSet) {
Future.delayed(Duration.zero, () {
restoreWindowPosition(
@@ -121,11 +120,6 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
}
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
@@ -134,6 +128,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter,
tabBuilder: (key, icon, label, themeConf) => Obx(() {
@@ -233,6 +228,7 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
// Specially configured for a better resize area and remote control.
childPadding: kDragToResizeAreaPadding,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
));
}
@@ -413,12 +409,14 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final display = args['display'];
final displays = args['displays'];
final screenRect = parseParamScreenRect(args);
final prePeerCount = tabController.length;
Future.delayed(Duration.zero, () async {
if (stateGlobal.fullscreen.isTrue) {
await WindowController.fromWindowId(windowId()).setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false);
}
await setNewConnectWindowFrame(windowId(), id!, display, screenRect);
await setNewConnectWindowFrame(
windowId(), id!, prePeerCount, display, screenRect);
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
await windowOnTop(windowId());
});

View File

@@ -32,14 +32,18 @@ class DesktopServerPage extends StatefulWidget {
class _DesktopServerPageState extends State<DesktopServerPage>
with WindowListener, AutomaticKeepAliveClientMixin {
final tabController = gFFI.serverModel.tabController;
@override
void initState() {
_DesktopServerPageState() {
gFFI.ffiModel.updateEventListener(gFFI.sessionId, "");
windowManager.addListener(this);
Get.put(tabController);
Get.put<DesktopTabController>(tabController);
tabController.onRemoved = (_, id) {
onRemoveId(id);
};
}
@override
void initState() {
windowManager.addListener(this);
super.initState();
}
@@ -79,7 +83,7 @@ class _DesktopServerPageState extends State<DesktopServerPage>
child: Consumer<ServerModel>(
builder: (context, serverModel, child) {
final body = Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
backgroundColor: Theme.of(context).colorScheme.background,
body: ConnectionManager(),
);
return isLinux
@@ -104,10 +108,11 @@ class ConnectionManager extends StatefulWidget {
State<StatefulWidget> createState() => ConnectionManagerState();
}
class ConnectionManagerState extends State<ConnectionManager> {
@override
void initState() {
gFFI.serverModel.updateClientState();
class ConnectionManagerState extends State<ConnectionManager>
with WidgetsBindingObserver {
final RxBool _block = false.obs;
ConnectionManagerState() {
gFFI.serverModel.tabController.onSelected = (client_id_str) {
final client_id = int.tryParse(client_id_str);
if (client_id != null) {
@@ -116,7 +121,7 @@ class ConnectionManagerState extends State<ConnectionManager> {
if (client != null) {
gFFI.chatModel.changeCurrentKey(MessageKey(client.peerId, client.id));
if (client.unreadChatMessageCount.value > 0) {
Future.delayed(Duration.zero, () {
WidgetsBinding.instance.addPostFrameCallback((_) {
client.unreadChatMessageCount.value = 0;
gFFI.chatModel.showChatPage(MessageKey(client.peerId, client.id));
});
@@ -127,9 +132,31 @@ class ConnectionManagerState extends State<ConnectionManager> {
}
};
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();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
@@ -165,8 +192,7 @@ class ConnectionManagerState extends State<ConnectionManager> {
selectedBorderColor: MyTheme.accent,
maxLabelWidth: 100,
tail: null, //buildScrollJumper(),
selectedTabBackgroundColor:
Theme.of(context).hintColor.withOpacity(0),
blockTab: allowRemoteCMModification() ? null : _block,
tabBuilder: (key, icon, label, themeConf) {
final client = serverModel.clients
.firstWhereOrNull((client) => client.id.toString() == key);
@@ -201,27 +227,28 @@ class ConnectionManagerState extends State<ConnectionManager> {
borderWidth;
final realChatPageWidth =
constrains.maxWidth - realClosedWidth;
return Row(children: [
final row = Row(children: [
if (constrains.maxWidth >
kConnectionManagerWindowSizeClosedChat.width)
Consumer<ChatModel>(
builder: (_, model, child) => SizedBox(
width: realChatPageWidth,
child: buildRemoteBlock(
child: Container(
decoration: BoxDecoration(
border: Border(
right: BorderSide(
color: Theme.of(context)
.dividerColor))),
child: buildSidePage()),
),
child: allowRemoteCMModification()
? buildSidePage()
: buildRemoteBlock(
child: buildSidePage(),
block: _block,
mask: true),
)),
SizedBox(
width: realClosedWidth,
child:
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;
}
});
gFFI.serverModel.tabController.onSelected?.call(client.id.toString());
// 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());
});
}
@override
@@ -714,7 +744,8 @@ class _CmControlPanel extends StatelessWidget {
child: buildButton(context,
color: MyTheme.accent,
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>;
if (devices.isEmpty) {
msgBox(
@@ -740,13 +771,14 @@ class _CmControlPanel extends StatelessWidget {
value: d,
height: 18,
padding: EdgeInsets.zero,
onTap: () => AudioInput.setDevice(d),
onTap: () => AudioInput.setDevice(d, true, true),
child: IgnorePointer(
child: RadioMenuButton(
value: d,
groupValue: currentDevice,
onChanged: (v) {
if (v != null) AudioInput.setDevice(v);
if (v != null)
AudioInput.setDevice(v, true, true);
},
child: Container(
child: Text(
@@ -1043,6 +1075,10 @@ class _CmControlPanel extends StatelessWidget {
}
void checkClickTime(int id, Function() callback) async {
if (allowRemoteCMModification()) {
callback();
return;
}
var clickCallbackTime = DateTime.now().millisecondsSinceEpoch;
await bind.cmCheckClickTime(connId: id);
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 {
_FileTransferLogPage({Key? key}) : super(key: key);
@@ -1116,6 +1157,16 @@ class __FileTransferLogPageState extends State<_FileTransferLogPage> {
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;
} else if (isLinux) {
localPlatform = kPeerPlatformLinux;
} else if (isWebOnWindows || isWebOnLinux) {
localPlatform = kPeerPlatformWebDesktop;
}
// to-do: web desktop support ?
return localPlatform;
}

View File

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

View File

@@ -38,18 +38,16 @@ class PopupMenuChildrenItem<T> extends mod_menu.PopupMenuEntry<T> {
@override
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>> createState() =>
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>>();
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>>(enabled?.value);
}
class MyPopupMenuItemState<T, W extends PopupMenuChildrenItem<T>>
extends State<W> {
RxBool enabled = true.obs;
@override
void initState() {
super.initState();
if (widget.enabled != null) {
enabled.value = widget.enabled!.value;
MyPopupMenuItemState(bool? e) {
if (e != null) {
enabled.value = e;
}
}

View File

@@ -372,7 +372,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
initState() {
super.initState();
Future.delayed(Duration.zero, () async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
_fractionX.value = double.tryParse(await bind.sessionGetOption(
sessionId: widget.ffi.sessionId,
arg: 'remote-menubar-drag-x') ??
@@ -436,6 +436,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
shadowColor: MyTheme.color(context).shadow,
borderRadius: borderRadius,
child: _DraggableShowHide(
id: widget.id,
sessionId: widget.ffi.sessionId,
dragging: _dragging,
fractionX: _fractionX,
@@ -452,8 +453,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
Widget _buildToolbar(BuildContext context) {
final List<Widget> toolbarItems = [];
toolbarItems.add(_PinMenu(state: widget.state));
if (!isWebDesktop) {
toolbarItems.add(_PinMenu(state: widget.state));
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
}
@@ -478,8 +479,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
setFullscreen: _setFullscreen,
));
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
if (!isWeb) {
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
}
if (!isWeb) toolbarItems.add(_RecordMenu());
@@ -1032,11 +1033,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
FFI get ffi => widget.ffi;
String get id => widget.id;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
_screenAdjustor.updateScreen();
@@ -1052,15 +1048,11 @@ class _DisplayMenuState extends State<_DisplayMenu> {
ffi: widget.ffi,
screenAdjustor: _screenAdjustor,
),
if (pi.isRustDeskIdd)
_RustDeskVirtualDisplayMenu(
id: widget.id,
ffi: widget.ffi,
),
if (pi.isAmyuniIdd)
_AmyuniVirtualDisplayMenu(
id: widget.id,
if (showVirtualDisplayMenu(ffi))
_SubmenuButton(
ffi: widget.ffi,
menuChildren: getVirtualDisplayMenuChildren(ffi, id, null),
child: Text(translate("Virtual display")),
),
cursorToggles(),
Divider(),
@@ -1282,7 +1274,9 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
@override
void initState() {
super.initState();
_getLocalResolutionWayland();
WidgetsBinding.instance.addPostFrameCallback((_) {
_getLocalResolutionWayland();
});
}
Rect? scaledRect() {
@@ -1559,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 {
final String id;
final FFI ffi;
@@ -1741,6 +1586,7 @@ class _KeyboardMenu extends StatelessWidget {
viewMode(),
Divider(),
...toolbarToggles(),
...mobileActions(),
]);
}
@@ -1767,7 +1613,9 @@ class _KeyboardMenu extends StatelessWidget {
// If use flutter to grab keys, we can only use one mode.
// Map mode and Legacy mode, at least one of them is supported.
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(
sessionId: ffi.sessionId, mode: kKeyMapMode)) {
modeOnly = kKeyMapMode;
@@ -1871,12 +1719,47 @@ class _KeyboardMenu extends StatelessWidget {
if (value == null) return;
await bind.sessionToggleOption(
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,
ffi: ffi,
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 {
@@ -1898,34 +1781,49 @@ class _ChatMenuState extends State<_ChatMenu> {
@override
Widget build(BuildContext context) {
return _IconSubmenuButton(
tooltip: 'Chat',
key: chatButtonKey,
svg: 'assets/chat.svg',
ffi: widget.ffi,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
menuChildrenGetter: () => [textChat(), voiceCall()]);
if (isWeb) {
return buildTextChatButton();
} else {
return _IconSubmenuButton(
tooltip: 'Chat',
key: chatButtonKey,
svg: 'assets/chat.svg',
ffi: widget.ffi,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
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() {
return MenuButton(
child: Text(translate('Text chat')),
ffi: widget.ffi,
onPressed: () {
RenderBox? renderBox =
chatButtonKey.currentContext?.findRenderObject() as RenderBox?;
onPressed: _textChatOnPressed);
}
Offset? initPos;
if (renderBox != null) {
final pos = renderBox.localToGlobal(Offset.zero);
initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight);
}
widget.ffi.chatModel.changeCurrentKey(
MessageKey(widget.ffi.id, ChatModel.clientModeID));
widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos);
});
_textChatOnPressed() {
RenderBox? renderBox =
chatButtonKey.currentContext?.findRenderObject() as RenderBox?;
Offset? initPos;
if (renderBox != null) {
final pos = renderBox.localToGlobal(Offset.zero);
initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight);
}
widget.ffi.chatModel
.changeCurrentKey(MessageKey(widget.ffi.id, ChatModel.clientModeID));
widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos);
}
voiceCall() {
@@ -1950,28 +1848,31 @@ class _VoiceCallMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
menuChildrenGetter() {
final audioInput =
AudioInput(builder: (devices, currentDevice, setDevice) {
return Column(
children: devices
.map((d) => RdoMenuButton<String>(
child: Container(
child: Text(
d,
overflow: TextOverflow.ellipsis,
final audioInput = AudioInput(
builder: (devices, currentDevice, setDevice) {
return Column(
children: devices
.map((d) => RdoMenuButton<String>(
child: Container(
child: Text(
d,
overflow: TextOverflow.ellipsis,
),
constraints: BoxConstraints(maxWidth: 250),
),
constraints: BoxConstraints(maxWidth: 250),
),
value: d,
groupValue: currentDevice,
onChanged: (v) {
if (v != null) setDevice(v);
},
ffi: ffi,
))
.toList(),
);
});
value: d,
groupValue: currentDevice,
onChanged: (v) {
if (v != null) setDevice(v);
},
ffi: ffi,
))
.toList(),
);
},
isCm: false,
isVoiceCall: true,
);
return [
audioInput,
Divider(),
@@ -2333,6 +2234,7 @@ class RdoMenuButton<T> extends StatelessWidget {
}
class _DraggableShowHide extends StatefulWidget {
final String id;
final SessionID sessionId;
final RxDouble fractionX;
final RxBool dragging;
@@ -2344,6 +2246,7 @@ class _DraggableShowHide extends StatefulWidget {
const _DraggableShowHide({
Key? key,
required this.id,
required this.sessionId,
required this.fractionX,
required this.dragging,
@@ -2433,15 +2336,33 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
);
final isFullscreen = stateGlobal.fullscreen;
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(
mainAxisSize: MainAxisSize.min,
children: [
_buildDraggable(context),
Obx(() => TextButton(
onPressed: () {
Obx(() => buttonWrapper(
() {
widget.setFullscreen(!isFullscreen.value);
},
child: Tooltip(
Tooltip(
message: translate(
isFullscreen.isTrue ? 'Exit Fullscreen' : 'Fullscreen'),
child: Icon(
@@ -2452,12 +2373,12 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
),
),
)),
if (!isMacOS)
if (!isMacOS && !isWebDesktop)
Obx(() => Offstage(
offstage: isFullscreen.isFalse,
child: TextButton(
onPressed: () => widget.setMinimize(),
child: Tooltip(
child: buttonWrapper(
widget.setMinimize,
Tooltip(
message: translate('Minimize'),
child: Icon(
Icons.remove,
@@ -2466,11 +2387,11 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
),
),
)),
TextButton(
onPressed: () => setState(() {
buttonWrapper(
() => setState(() {
widget.toolbarState.switchShow(widget.sessionId);
}),
child: Obx((() => Tooltip(
Obx((() => Tooltip(
message:
translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
child: Icon(
@@ -2479,6 +2400,25 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
),
))),
),
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(

View File

@@ -227,11 +227,9 @@ typedef TabMenuBuilder = Widget Function(String key);
typedef LabelGetter = Rx<String> Function(String key);
/// [_lastClickTime], help to handle double click
int _lastClickTime =
DateTime.now().millisecondsSinceEpoch - bind.getDoubleClickTime() - 1000;
int _lastClickTime = 0;
// ignore: must_be_immutable
class DesktopTab extends StatelessWidget {
class DesktopTab extends StatefulWidget {
final bool showLogo;
final bool showTitle;
final bool showMinimize;
@@ -248,15 +246,12 @@ class DesktopTab extends StatelessWidget {
final Color? selectedTabBackgroundColor;
final Color? unSelectedTabBackgroundColor;
final Color? selectedBorderColor;
final RxBool? blockTab;
final DesktopTabController controller;
Rx<DesktopTabState> get state => controller.state;
final _scrollDebounce = Debouncer(delay: Duration(milliseconds: 50));
late final DesktopTabType tabType;
late final bool isMainWindow;
final RxList<String> invisibleTabKeys = RxList.empty();
DesktopTab({
@@ -277,38 +272,260 @@ class DesktopTab extends StatelessWidget {
this.selectedTabBackgroundColor,
this.unSelectedTabBackgroundColor,
this.selectedBorderColor,
}) : super(key: key) {
tabType = controller.tabType;
isMainWindow = tabType == DesktopTabType.main ||
tabType == DesktopTabType.cm ||
tabType == DesktopTabType.install;
}
this.blockTab,
}) : super(key: key);
static RxString tablabelGetter(String peerId) {
final alias = bind.mainGetPeerOptionSync(id: peerId, key: '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
Widget build(BuildContext context) {
return Column(children: [
Obx(() => Offstage(
offstage: !stateGlobal.showTabBar.isTrue ||
(kUseCompatibleUiMode && isHideSingleItem()),
child: SizedBox(
Obx(() {
if (stateGlobal.showTabBar.isTrue &&
!(kUseCompatibleUiMode && isHideSingleItem())) {
final showBottomDivider = _showTabBarBottomDivider(tabType);
return SizedBox(
height: _kTabBarHeight,
child: Column(
children: [
SizedBox(
height: _kTabBarHeight - 1,
height:
showBottomDivider ? _kTabBarHeight - 1 : _kTabBarHeight,
child: _buildBar(),
),
const Divider(
height: 1,
),
if (showBottomDivider)
const Divider(
height: 1,
),
],
),
))),
);
} else {
return Offstage();
}
}),
Expanded(
child: pageViewBuilder != null
? pageViewBuilder!(_buildPageView())
@@ -317,10 +534,15 @@ class DesktopTab extends StatelessWidget {
}
Widget _buildBlock({required Widget child}) {
if (tabType != DesktopTabType.main) {
if (blockTab != null) {
return buildRemoteBlock(
child: child,
block: blockTab!,
use: canBeBlocked,
mask: tabType == DesktopTabType.main);
} else {
return child;
}
return buildRemoteBlock(child: child, use: canBeBlocked);
}
List<Widget> _tabWidgets = [];
@@ -330,6 +552,13 @@ class DesktopTab extends StatelessWidget {
controller: state.value.pageController,
physics: NeverScrollableScrollPhysics(),
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.
/// [workaround] PageView children need an immutable list, after it has been passed into PageView
final tabLen = state.value.tabs.length;
@@ -457,7 +686,6 @@ class DesktopTab extends StatelessWidget {
// hide simulated action buttons when we in compatible ui mode, because of reusing system title bar.
WindowActionPanel(
isMainWindow: isMainWindow,
tabType: tabType,
state: state,
tabController: controller,
invisibleTabKeys: invisibleTabKeys,
@@ -475,7 +703,6 @@ class DesktopTab extends StatelessWidget {
class WindowActionPanel extends StatefulWidget {
final bool isMainWindow;
final DesktopTabType tabType;
final Rx<DesktopTabState> state;
final DesktopTabController tabController;
@@ -491,7 +718,6 @@ class WindowActionPanel extends StatefulWidget {
const WindowActionPanel(
{Key? key,
required this.isMainWindow,
required this.tabType,
required this.state,
required this.tabController,
required this.invisibleTabKeys,
@@ -509,180 +735,7 @@ class WindowActionPanel extends StatefulWidget {
}
}
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();
}
class WindowActionPanelState extends State<WindowActionPanel> {
bool showTabDowndown() {
return widget.tabController.state.value.tabs.length > 1 &&
(widget.tabController.tabType == DesktopTabType.remoteScreen ||
@@ -703,72 +756,69 @@ class WindowActionPanelState extends State<WindowActionPanel>
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Obx(() => Offstage(
offstage:
!(showTabDowndown() && existingInvisibleTab().isNotEmpty),
child: _TabDropDownButton(
controller: widget.tabController,
labelGetter: widget.labelGetter,
tabkeys: existingInvisibleTab()),
)),
Offstage(offstage: widget.tail == null, child: widget.tail),
Offstage(
offstage: kUseCompatibleUiMode,
child: Row(
Obx(() {
if (showTabDowndown() && existingInvisibleTab().isNotEmpty) {
return _TabDropDownButton(
controller: widget.tabController,
labelGetter: widget.labelGetter,
tabkeys: existingInvisibleTab());
} else {
return Offstage();
}
}),
if (widget.tail != null) widget.tail!,
if (!kUseCompatibleUiMode)
Row(
children: [
Offstage(
offstage: !widget.showMinimize || isMacOS,
child: ActionIcon(
message: 'Minimize',
icon: IconFont.min,
onTap: () {
if (widget.isMainWindow) {
windowManager.minimize();
} else {
WindowController.fromWindowId(kWindowId!).minimize();
}
},
isClose: false,
)),
Offstage(
offstage: !widget.showMaximize || isMacOS,
child: Obx(() => ActionIcon(
message: stateGlobal.isMaximized.isTrue
? 'Restore'
: 'Maximize',
icon: stateGlobal.isMaximized.isTrue
? IconFont.restore
: IconFont.max,
onTap: bind.isIncomingOnly() && isInHomePage()
? null
: _toggleMaximize,
isClose: false,
))),
Offstage(
offstage: !widget.showClose || isMacOS,
child: ActionIcon(
message: 'Close',
icon: IconFont.close,
onTap: () async {
final res = await widget.onClose?.call() ?? true;
if (res) {
// hide for all window
// note: the main window can be restored by tray icon
Future.delayed(Duration.zero, () async {
if (widget.isMainWindow) {
await windowManager.close();
} else {
await WindowController.fromWindowId(kWindowId!)
.close();
}
});
}
},
isClose: true,
))
if (widget.showMinimize && !isMacOS)
ActionIcon(
message: 'Minimize',
icon: IconFont.min,
onTap: () {
if (widget.isMainWindow) {
windowManager.minimize();
} else {
WindowController.fromWindowId(kWindowId!).minimize();
}
},
isClose: false,
),
if (widget.showMaximize && !isMacOS)
Obx(() => ActionIcon(
message: stateGlobal.isMaximized.isTrue
? 'Restore'
: 'Maximize',
icon: stateGlobal.isMaximized.isTrue
? IconFont.restore
: IconFont.max,
onTap: bind.isIncomingOnly() && isInHomePage()
? null
: _toggleMaximize,
isClose: false,
)),
if (widget.showClose && !isMacOS)
ActionIcon(
message: 'Close',
icon: IconFont.close,
onTap: () async {
final res = await widget.onClose?.call() ?? true;
if (res) {
// hide for all window
// note: the main window can be restored by tray icon
Future.delayed(Duration.zero, () async {
if (widget.isMainWindow) {
await windowManager.close();
} else {
await WindowController.fromWindowId(kWindowId!)
.close();
}
});
}
},
isClose: true,
)
],
),
),
],
);
}
@@ -1121,7 +1171,10 @@ class _TabState extends State<_Tab> with RestorationMixin {
child: Row(
children: [
SizedBox(
height: _kTabBarHeight,
// _kTabBarHeight also displays normally
height: _showTabBarBottomDivider(widget.tabType)
? _kTabBarHeight - 1
: _kTabBarHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -1172,22 +1225,26 @@ class _CloseButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: _kIconSize,
child: Offstage(
offstage: !visible,
child: InkWell(
hoverColor: MyTheme.tabbar(context).closeHoverColor,
customBorder: const CircleBorder(),
onTap: () => onClose(),
child: Icon(
Icons.close,
size: _kIconSize,
color: tabSelected
? MyTheme.tabbar(context).selectedIconColor
: MyTheme.tabbar(context).unSelectedIconColor,
),
),
)).paddingOnly(left: 10);
width: _kIconSize,
child: () {
if (visible) {
return InkWell(
hoverColor: MyTheme.tabbar(context).closeHoverColor,
customBorder: const CircleBorder(),
onTap: () => onClose(),
child: Icon(
Icons.close,
size: _kIconSize,
color: tabSelected
? MyTheme.tabbar(context).selectedIconColor
: MyTheme.tabbar(context).unSelectedIconColor,
),
);
} else {
return Offstage();
}
}())
.paddingOnly(left: 10);
}
}
@@ -1216,13 +1273,7 @@ class ActionIcon extends StatefulWidget {
}
class _ActionIconState extends State<ActionIcon> {
var hover = false.obs;
@override
void initState() {
super.initState();
hover.value = false;
}
final hover = false.obs;
@override
Widget build(BuildContext context) {
@@ -1341,27 +1392,30 @@ class _TabDropDownButtonState extends State<_TabDropDownButton> {
child: InkWell(child: Text(label)),
),
Obx(
() => Offstage(
offstage: !(tabInfo?.onTabCloseButton != null &&
menuHover.value),
child: InkWell(
onTap: () {
tabInfo?.onTabCloseButton?.call();
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
onHover: (event) =>
setState(() => btnHover.value = true),
onExit: (event) =>
setState(() => btnHover.value = false),
child: Icon(Icons.close,
color:
btnHover.value ? Colors.red : null))),
),
)
() {
if (tabInfo?.onTabCloseButton != null &&
menuHover.value) {
return InkWell(
onTap: () {
tabInfo?.onTabCloseButton?.call();
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
onHover: (event) =>
setState(() => btnHover.value = true),
onExit: (event) =>
setState(() => btnHover.value = false),
child: Icon(Icons.close,
color:
btnHover.value ? Colors.red : null)));
} else {
return Offstage();
}
},
),
],
),
),
@@ -1373,6 +1427,10 @@ class _TabDropDownButtonState extends State<_TabDropDownButton> {
}
}
bool _showTabBarBottomDivider(DesktopTabType tabType) {
return tabType == DesktopTabType.main || tabType == DesktopTabType.install;
}
class TabbarTheme extends ThemeExtension<TabbarTheme> {
final Color? selectedTabIconColor;
final Color? unSelectedTabIconColor;

View File

@@ -36,6 +36,7 @@ WindowType? kWindowType;
late List<String> kBootArgs;
Future<void> main(List<String> args) async {
earlyAssert();
WidgetsFlutterBinding.ensureInitialized();
debugPrint("launch args: $args");
@@ -161,7 +162,7 @@ void runMobileApp() async {
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
gFFI.userModel.refreshCurrentUser();
runApp(App());
if (!isWeb) await initUniLinks();
await initUniLinks();
}
void runMultiWindow(
@@ -260,7 +261,7 @@ showCmWindow({bool isStartup = false}) async {
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat, alwaysOnTop: true);
await windowManager.waitUntilReadyToShow(windowOptions, null);
bind.mainHideDocker();
bind.mainHideDock();
await Future.wait([
windowManager.show(),
windowManager.focus(),
@@ -288,14 +289,14 @@ hideCmWindow({bool isStartup = false}) async {
size: kConnectionManagerWindowSizeClosedChat);
windowManager.setOpacity(0);
await windowManager.waitUntilReadyToShow(windowOptions, null);
bind.mainHideDocker();
bind.mainHideDock();
await windowManager.minimize();
await windowManager.hide();
_isCmReadyToShow = true;
} else if (_isCmReadyToShow) {
if (await windowManager.getOpacity() != 0) {
await windowManager.setOpacity(0);
bind.mainHideDocker();
bind.mainHideDock();
await windowManager.minimize();
await windowManager.hide();
}
@@ -372,7 +373,7 @@ class App extends StatefulWidget {
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
class _AppState extends State<App> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
@@ -396,6 +397,34 @@ class _AppState extends State<App> {
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
@@ -416,7 +445,9 @@ class _AppState extends State<App> {
child: GetMaterialApp(
navigatorKey: globalKey,
debugShowCheckedModeBanner: false,
title: 'RustDesk',
title: isWeb
? '${bind.mainGetAppNameSync()} Web Client V2 (Preview)'
: bind.mainGetAppNameSync(),
theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme,
themeMode: MyTheme.currentThemeMode(),
@@ -447,7 +478,8 @@ class _AppState extends State<App> {
: (context, child) {
child = _keepScaleBuilder(context, child);
child = botToastBuilder(context, child);
if (isDesktop && desktopType == DesktopType.main) {
if ((isDesktop && desktopType == DesktopType.main) ||
isWebDesktop) {
child = keyListenerBuilder(context, child);
}
if (isLinux) {
@@ -475,7 +507,7 @@ _registerEventHandler() {
platformFFI.registerEventHandler('theme', 'theme', (evt) async {
String? dark = evt['dark'];
if (dark != null) {
MyTheme.changeDarkMode(MyTheme.themeModeFromString(dark));
await MyTheme.changeDarkMode(MyTheme.themeModeFromString(dark));
}
});
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:flutter/material.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:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import '../../common.dart';
import '../../common/widgets/login.dart';
import '../../common/widgets/peer_tab_page.dart';
import '../../common/widgets/autocomplete.dart';
import '../../consts.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import 'home_page.dart';
import 'scan_page.dart';
import 'settings_page.dart';
/// Connection page for connecting to a remote peer.
class ConnectionPage extends StatefulWidget implements PageShape {
ConnectionPage({Key? key}) : super(key: key);
ConnectionPage({Key? key, required this.appBarActions}) : super(key: key);
@override
final icon = const Icon(Icons.connected_tv);
@@ -30,7 +28,7 @@ class ConnectionPage extends StatefulWidget implements PageShape {
final title = translate("Connection");
@override
final appBarActions = isWeb ? <Widget>[const WebMenu()] : <Widget>[];
final List<Widget> appBarActions;
@override
State<ConnectionPage> createState() => _ConnectionPageState();
@@ -50,33 +48,43 @@ class _ConnectionPageState extends State<ConnectionPage> {
bool isPeersLoaded = false;
StreamSubscription? _uniLinksSubscription;
_ConnectionPageState() {
if (!isWeb) _uniLinksSubscription = listenUniLinks();
_idController.addListener(() {
_idEmpty.value = _idController.text.isEmpty;
});
Get.put<IDTextEditingController>(_idController);
}
@override
void initState() {
super.initState();
if (!isWeb) _uniLinksSubscription = listenUniLinks();
if (_idController.text.isEmpty) {
() async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId();
if (lastRemoteId != _idController.id) {
setState(() {
_idController.id = lastRemoteId;
});
}
}();
});
}
if (isAndroid) {
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 {
_updateUrl = await bind.mainGetSoftwareUpdateUrl();
if (_updateUrl.isNotEmpty) setState(() {});
bind.mainGetSoftwareUpdateUrl();
});
}
}
_idController.addListener(() {
_idEmpty.value = _idController.text.isEmpty;
});
Get.put<IDTextEditingController>(_idController);
}
@override
@@ -204,6 +212,8 @@ class _ConnectionPageState extends State<ConnectionPage> {
FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted) {
fieldTextEditingController.text = _idController.text;
Get.put<TextEditingController>(
fieldTextEditingController);
fieldFocusNode.addListener(() async {
_idEmpty.value =
fieldTextEditingController.text.isEmpty;
@@ -250,6 +260,9 @@ class _ConnectionPageState extends State<ConnectionPage> {
),
),
inputFormatters: [IDTextInputFormatter()],
onSubmitted: (_) {
onConnect();
},
);
},
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(
alignment: Alignment.topCenter,
child: Container(constraints: kMobilePageConstraints, child: w));
child: Container(constraints: kMobilePageConstraints, child: child));
}
@override
@@ -351,76 +370,13 @@ class _ConnectionPageState extends State<ConnectionPage> {
if (Get.isRegistered<IDTextEditingController>()) {
Get.delete<IDTextEditingController>();
}
if (Get.isRegistered<TextEditingController>()) {
Get.delete<TextEditingController>();
}
if (!bind.isCustomClient()) {
platformFFI.unregisterEventHandler(
kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish);
}
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,36 +204,54 @@ class _FileManagerPageState extends State<FileManagerPage> {
setState(() {});
} else if (v == "folder") {
final name = TextEditingController();
gFFI.dialogManager
.show((setState, close, context) => CustomAlertDialog(
title: Text(translate("Create Folder")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: InputDecoration(
labelText: translate(
"Please enter the folder name"),
),
controller: name,
),
],
String? errorText;
gFFI.dialogManager.show((setState, close, context) {
name.addListener(() {
if (errorText != null) {
setState(() {
errorText = null;
});
}
});
return CustomAlertDialog(
title: Text(translate("Create Folder")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: InputDecoration(
labelText:
translate("Please enter the folder name"),
errorText: errorText,
),
actions: [
dialogButton("Cancel",
onPressed: () => close(false),
isOutline: true),
dialogButton("OK", onPressed: () {
if (name.value.text.isNotEmpty) {
currentFileController.createDir(
PathUtil.join(
currentDir.path,
name.value.text,
currentOptions.isWindows));
close();
}
})
]));
controller: name,
),
],
),
actions: [
dialogButton("Cancel",
onPressed: () => close(false), isOutline: true),
dialogButton("OK", onPressed: () {
if (name.value.text.isNotEmpty) {
if (!PathUtil.validName(
name.value.text,
currentFileController
.options.value.isWindows)) {
setState(() {
errorText =
translate("Invalid folder name");
});
return;
}
currentFileController.createDir(PathUtil.join(
currentDir.path,
name.value.text,
currentOptions.isWindows));
close();
}
})
]);
});
} else if (v == "hidden") {
currentFileController.toggleShowHidden();
}
@@ -497,7 +515,15 @@ class _FileManagerViewState extends State<FileManagerView> {
child: Text(translate("Properties")),
value: "properties",
enabled: false,
)
),
if (!entries[index].isDrive &&
versionCmp(gFFI.ffiModel.pi.version,
"1.3.0") >=
0)
PopupMenuItem(
child: Text(translate("Rename")),
value: "rename",
)
];
},
onSelected: (v) {
@@ -509,6 +535,9 @@ class _FileManagerViewState extends State<FileManagerView> {
_selectedItems.clear();
widget.selectMode.toggle(isLocal);
setState(() {});
} else if (v == "rename") {
controller.renameAction(
entries[index], isLocal);
}
}),
onTap: () {

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/mobile/pages/server_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 '../../common.dart';
import '../../common/widgets/chat_page.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'connection_page.dart';
abstract class PageShape extends Widget {
@@ -45,7 +47,11 @@ class HomePageState extends State<HomePage> {
void initPages() {
_pages.clear();
if (!bind.isIncomingOnly()) _pages.add(ConnectionPage());
if (!bind.isIncomingOnly()) {
_pages.add(ConnectionPage(
appBarActions: [],
));
}
if (isAndroid && !bind.isOutgoingOnly()) {
_chatPageTabIndex = _pages.length;
_pages.addAll([ChatPage(type: ChatPageType.mobileMain), ServerPage()]);
@@ -149,18 +155,80 @@ class HomePageState extends State<HomePage> {
}
class WebHomePage extends StatelessWidget {
final connectionPage = ConnectionPage();
final connectionPage =
ConnectionPage(appBarActions: <Widget>[const WebSettingsPage()]);
@override
Widget build(BuildContext context) {
stateGlobal.isInMainPage = true;
handleUnilink(context);
return Scaffold(
// backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: Text(bind.mainGetAppNameSync()),
title: Text("${bind.mainGetAppNameSync()} (Preview)"),
actions: connectionPage.appBarActions,
),
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;
@override
State<RemotePage> createState() => _RemotePageState();
State<RemotePage> createState() => _RemotePageState(id);
}
class _RemotePageState extends State<RemotePage> {
@@ -55,6 +55,18 @@ class _RemotePageState extends State<RemotePage> {
InputModel get inputModel => gFFI.inputModel;
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
void initState() {
super.initState();
@@ -77,12 +89,9 @@ class _RemotePageState extends State<RemotePage> {
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
keyboardSubscription =
keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged);
initSharedStates(widget.id);
gFFI.chatModel
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
_blockableOverlayState.applyFfi(gFFI);
gFFI.dialogManager.loadMobileActionsOverlayVisible();
}
@override
@@ -98,6 +107,7 @@ class _RemotePageState extends State<RemotePage> {
_physicalFocusNode.dispose();
await gFFI.close();
_timer?.cancel();
_composingTimer?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
@@ -133,6 +143,7 @@ class _RemotePageState extends State<RemotePage> {
gFFI.ffiModel.pi.version.isNotEmpty) {
gFFI.invokeMethod("enable_soft_keyboard", false);
}
_composingTimer?.cancel();
} else {
_timer?.cancel();
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
@@ -145,41 +156,70 @@ class _RemotePageState extends State<RemotePage> {
setState(() {});
}
// handle mobile virtual keyboard
void handleSoftKeyboardInput(String newValue) {
void _handleIOSSoftKeyboardInput(String newValue) {
var oldValue = _value;
_value = newValue;
if (isIOS) {
var i = newValue.length - 1;
for (; i >= 0 && newValue[i] != '\1'; --i) {}
var j = oldValue.length - 1;
for (; j >= 0 && oldValue[j] != '\1'; --j) {}
if (i < j) j = i;
newValue = newValue.substring(j + 1);
oldValue = oldValue.substring(j + 1);
var common = 0;
for (;
common < oldValue.length &&
common < newValue.length &&
newValue[common] == oldValue[common];
++common) {}
for (i = 0; i < oldValue.length - common; ++i) {
inputModel.inputKey('VK_BACK');
}
if (newValue.length > common) {
var s = newValue.substring(common);
if (s.length > 1) {
bind.sessionInputString(sessionId: sessionId, value: s);
} else {
inputChar(s);
}
var i = newValue.length - 1;
for (; i >= 0 && newValue[i] != '1'; --i) {}
var j = oldValue.length - 1;
for (; j >= 0 && oldValue[j] != '1'; --j) {}
if (i < j) j = i;
var subNewValue = newValue.substring(j + 1);
var subOldValue = oldValue.substring(j + 1);
// get common prefix of subNewValue and subOldValue
var common = 0;
for (;
common < subOldValue.length &&
common < subNewValue.length &&
subNewValue[common] == subOldValue[common];
++common) {}
// get newStr from subNewValue
var newStr = "";
if (subNewValue.length > common) {
newStr = subNewValue.substring(common);
}
// 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;
}
}
// 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 &&
newValue.isNotEmpty &&
oldValue[0] == '\1' &&
newValue[0] != '\1') {
oldValue[0] == '1' &&
newValue[0] != '1') {
// clipboard
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) {
if (char == '\n') {
char = 'VK_RETURN';
@@ -227,6 +280,7 @@ class _RemotePageState extends State<RemotePage> {
gFFI.invokeMethod("enable_soft_keyboard", true);
// destroy first, so that our _value trick can work
_value = initText;
_textController.text = _value;
setState(() => _showEdit = false);
_timer?.cancel();
_timer = Timer(kMobileDelaySoftKeyboard, () {
@@ -242,12 +296,10 @@ class _RemotePageState extends State<RemotePage> {
});
}
bool get keyboard => gFFI.ffiModel.permissions['keyboard'] != false;
Widget _bottomWidget() => _showGestureHelp
? getGestureHelp()
: (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
? getBottomAppBar(keyboard)
? getBottomAppBar()
: Offstage());
@override
@@ -314,7 +366,7 @@ class _RemotePageState extends State<RemotePage> {
return Container(
color: kColorCanvas,
child: isWebDesktop
? getBodyForDesktopWithListener(keyboard)
? getBodyForDesktopWithListener()
: SafeArea(
child:
OrientationBuilder(builder: (ctx, orientation) {
@@ -346,9 +398,9 @@ class _RemotePageState extends State<RemotePage> {
}
Widget getRawPointerAndKeyBody(Widget child) {
final keyboard = gFFI.ffiModel.permissions['keyboard'] != false;
final ffiModel = Provider.of<FfiModel>(context);
return RawPointerMouseRegion(
cursor: keyboard ? SystemMouseCursors.none : MouseCursor.defer,
cursor: ffiModel.keyboard ? SystemMouseCursors.none : MouseCursor.defer,
inputModel: inputModel,
// Disable RawKeyFocusScope before the connecting is established.
// 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(
elevation: 10,
color: MyTheme.accent,
@@ -387,7 +440,7 @@ class _RemotePageState extends State<RemotePage> {
},
)
] +
(isWebDesktop
(isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard
? []
: gFFI.ffiModel.isPeerAndroid
? [
@@ -491,7 +544,7 @@ class _RemotePageState extends State<RemotePage> {
autofocus: true,
focusNode: _mobileFocusNode,
maxLines: null,
initialValue: _value,
controller: _textController,
// trick way to make backspace work always
keyboardType: TextInputType.multiline,
onChanged: handleSoftKeyboardInput,
@@ -499,48 +552,84 @@ class _RemotePageState extends State<RemotePage> {
),
];
if (showCursorPaint) {
paints.add(CursorPaint());
paints.add(CursorPaint(widget.id));
}
return paints;
}()));
}
Widget getBodyForDesktopWithListener(bool keyboard) {
Widget getBodyForDesktopWithListener() {
final ffiModel = Provider.of<FfiModel>(context);
var paints = <Widget>[ImagePaint()];
if (showCursorPaint) {
final cursor = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'show-remote-cursor');
if (keyboard || cursor) {
paints.add(CursorPaint());
if (ffiModel.keyboard || cursor) {
paints.add(CursorPaint(widget.id));
}
}
return Container(
color: MyTheme.canvasColor, child: Stack(children: paints));
}
List<TTextMenu> _getMobileActionMenus() {
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
!gFFI.ffiModel.keyboard) {
return [];
}
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
if (!enabled) return [];
return [
TTextMenu(
child: Text(translate('Back')),
onPressed: () => gFFI.inputModel.onMobileBack(),
),
TTextMenu(
child: Text(translate('Home')),
onPressed: () => gFFI.inputModel.onMobileHome(),
),
TTextMenu(
child: Text(translate('Apps')),
onPressed: () => gFFI.inputModel.onMobileApps(),
),
TTextMenu(
child: Text(translate('Volume up')),
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
),
TTextMenu(
child: Text(translate('Volume down')),
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
),
TTextMenu(
child: Text(translate('Power')),
onPressed: () => gFFI.inputModel.onMobilePower(),
),
];
}
void showActions(String id) async {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
final mobileActionMenus = _getMobileActionMenus();
final menus = toolbarControls(context, id, gFFI);
getChild(TTextMenu menu) {
if (menu.trailingIcon != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
menu.child,
menu.trailingIcon!,
]);
} else {
return menu.child;
}
}
final more = menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(child: getChild(e.value), value: e.key))
.toList();
final List<PopupMenuEntry<int>> more = [
...mobileActionMenus
.asMap()
.entries
.map((e) =>
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList(),
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
...menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(
child: e.value.getChild(),
value: e.key + mobileActionMenus.length))
.toList(),
];
() async {
var index = await showMenu(
context: context,
@@ -548,8 +637,12 @@ class _RemotePageState extends State<RemotePage> {
items: more,
elevation: 8,
);
if (index != null && index < menus.length) {
menus[index].onPressed.call();
if (index != null) {
if (index < mobileActionMenus.length) {
mobileActionMenus[index].onPressed.call();
} else if (index < mobileActionMenus.length + more.length) {
menus[index - mobileActionMenus.length].onPressed.call();
}
}
}();
}
@@ -569,9 +662,11 @@ class _RemotePageState extends State<RemotePage> {
child: Text(translate(label), style: labelStyle),
trailingIcon: Transform.scale(
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
child: IconButton(
onPressed: onPressed,
icon: icon,
child: IgnorePointer(
child: IconButton(
onPressed: null,
icon: icon,
),
),
),
onPressed: onPressed,
@@ -602,23 +697,11 @@ class _RemotePageState extends State<RemotePage> {
),
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
.asMap()
.entries
.map((e) => PopupMenuItem<int>(child: getChild(e.value), value: e.key))
.map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList();
Future.delayed(Duration.zero, () async {
final size = MediaQuery.of(context).size;
@@ -712,11 +795,6 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
onPressed: onPressed);
}
@override
void initState() {
super.initState();
}
_updateRect() {
RenderObject? renderObject = _key.currentContext?.findRenderObject();
if (renderObject == null) {
@@ -885,26 +963,52 @@ class ImagePaint extends StatelessWidget {
}
class CursorPaint extends StatelessWidget {
late final String id;
CursorPaint(this.id);
@override
Widget build(BuildContext context) {
final m = Provider.of<CursorModel>(context);
final c = Provider.of<CanvasModel>(context);
final ffiModel = Provider.of<FfiModel>(context);
final adjust = gFFI.cursorModel.adjustForKeyboard();
var s = c.scale;
final s = c.scale;
double hotx = m.hotx;
double hoty = m.hoty;
if (m.image == null) {
var image = m.image;
if (image == null) {
if (preDefaultCursor.image != null) {
image = preDefaultCursor.image;
hotx = preDefaultCursor.image!.width / 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(
painter: ImagePainter(
image: m.image ?? preDefaultCursor.image,
x: m.x * s - hotx + c.x,
y: m.y * s - hoty + c.y - adjust,
scale: 1),
image: image,
x: (m.x - hotx) * factor + c.x / s2,
y: (m.y - hoty) * factor + (c.y - adjust) / s2,
scale: s2),
);
}
}
@@ -982,22 +1086,40 @@ void showOptions(
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
final radios = [
for (var e in viewStyleRadios)
Obx(() => getRadio<String>(e.child, e.value, viewStyle.value, (v) {
e.onChanged?.call(v);
if (v != null) viewStyle.value = v;
})),
Obx(() => getRadio<String>(
e.child,
e.value,
viewStyle.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) viewStyle.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in imageQualityRadios)
Obx(() => getRadio<String>(e.child, e.value, imageQuality.value, (v) {
e.onChanged?.call(v);
if (v != null) imageQuality.value = v;
})),
Obx(() => getRadio<String>(
e.child,
e.value,
imageQuality.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) imageQuality.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in codecRadios)
Obx(() => getRadio<String>(e.child, e.value, codec.value, (v) {
e.onChanged?.call(v);
if (v != null) codec.value = v;
})),
Obx(() => getRadio<String>(
e.child,
e.value,
codec.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) codec.value = v;
}
: null)),
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
];
final rxCursorToggleValues = cursorToggles.map((e) => e.value.obs).toList();
@@ -1008,10 +1130,12 @@ void showOptions(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
value: rxCursorToggleValues[e.key].value,
onChanged: (v) {
e.value.onChanged?.call(v);
if (v != null) rxCursorToggleValues[e.key].value = v;
},
onChanged: e.value.onChanged != null
? (v) {
e.value.onChanged?.call(v);
if (v != null) rxCursorToggleValues[e.key].value = v;
}
: null,
title: e.value.child)))
.toList();
@@ -1023,10 +1147,12 @@ void showOptions(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
value: rxToggleValues[e.key].value,
onChanged: (v) {
e.value.onChanged?.call(v);
if (v != null) rxToggleValues[e.key].value = v;
},
onChanged: e.value.onChanged != null
? (v) {
e.value.onChanged?.call(v);
if (v != null) rxToggleValues[e.key].value = v;
}
: null,
title: e.value.child)))
.toList();
final toggles = [
@@ -1046,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(
content: Column(
mainAxisSize: MainAxisSize.min,
children: displays + radios + toggles + [privacyModeWidget]),
children: displays +
radios +
popupDialogMenus +
toggles +
[privacyModeWidget]),
);
}, 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) {
final old = isMac ? gFFI.inputModel.command : gFFI.inputModel.ctrl;
if (isMac) {

View File

@@ -19,95 +19,48 @@ class ScanPage extends StatefulWidget {
class _ScanPageState extends State<ScanPage> {
QRViewController? controller;
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
void reassemble() {
super.reassemble();
if (isAndroid) {
if (isAndroid && controller != null) {
controller!.pauseCamera();
} else if (controller != null) {
controller!.resumeCamera();
}
controller!.resumeCamera();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR'),
actions: [
IconButton(
color: Colors.white,
icon: Icon(Icons.image_search),
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));
appBar: AppBar(
title: const Text('Scan QR'),
actions: [
_buildImagePickerButton(),
_buildFlashToggleButton(),
_buildCameraSwitchButton(),
],
),
body: _buildQrView(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 ||
MediaQuery.of(context).size.height < 400)
var scanArea = MediaQuery.of(context).size.width < 400 ||
MediaQuery.of(context).size.height < 400
? 150.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(
key: qrKey,
onQRViewCreated: _onQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: Colors.red,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: scanArea),
borderColor: Colors.red,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: scanArea,
),
onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p),
);
}
@@ -116,7 +69,7 @@ class _ScanPageState extends State<ScanPage> {
setState(() {
this.controller = controller;
});
controller.scannedDataStream.listen((scanData) {
scanSubscription = controller.scannedDataStream.listen((scanData) {
if (scanData.code != null) {
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
void dispose() {
scanSubscription?.cancel();
controller?.dispose();
super.dispose();
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.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/models/chat_model.dart';
import 'package:get/get.dart';
@@ -22,7 +23,22 @@ class ServerPage extends StatefulWidget implements PageShape {
final icon = const Icon(Icons.mobile_screen_share);
@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>(
tooltip: "",
icon: const Icon(Icons.more_vert),
@@ -101,18 +117,27 @@ class ServerPage extends StatefulWidget implements PageShape {
),
];
},
onSelected: (value) {
onSelected: (value) async {
if (value == "changeID") {
changeIdDialog();
} else if (value == "setPermanentPassword") {
setPermanentPasswordDialog(gFFI.dialogManager);
setPasswordDialog();
} else if (value == "setTemporaryPasswordLength") {
setTemporaryPasswordLengthDialog(gFFI.dialogManager);
} else if (value == kUsePermanentPassword ||
value == kUseTemporaryPassword ||
value == kUseBothPasswords) {
bind.mainSetOption(key: kOptionVerificationMethod, value: value);
gFFI.serverModel.updatePasswordModel();
callback() {
bind.mainSetOption(key: kOptionVerificationMethod, value: value);
gFFI.serverModel.updatePasswordModel();
}
if (value == kUsePermanentPassword &&
(await bind.mainGetPermanentPassword()).isEmpty) {
setPasswordDialog(notEmptyCallback: callback);
} else {
callback();
}
} else if (value.startsWith("AcceptSessionsVia")) {
value = value.substring("AcceptSessionsVia".length);
if (value == "Password") {
@@ -126,10 +151,10 @@ class ServerPage extends StatefulWidget implements PageShape {
})
];
ServerPage({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => _ServerPageState();
Widget build(BuildContext context) {
return actions[0];
}
}
class _ServerPageState extends State<ServerPage> {
@@ -162,7 +187,7 @@ class _ServerPageState extends State<ServerPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
buildPresetPasswordWarning(),
buildPresetPasswordWarningMobile(),
gFFI.serverModel.isStart
? ServerInfo()
: ServiceNotRunningNotification(),

View File

@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.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:provider/provider.dart';
import 'package:settings_ui/settings_ui.dart';
@@ -83,18 +85,17 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _fingerprint = "";
var _buildDate = "";
var _autoDisconnectTimeout = "";
var _hideServer = false;
var _hideProxy = false;
var _hideNetwork = false;
var _enableTrustedDevices = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_SettingsState() {
_enableAbr = option2bool(
kOptionEnableAbr, bind.mainGetOptionSync(key: kOptionEnableAbr));
_denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery,
bind.mainGetOptionSync(key: kOptionEnableLanDiscovery));
_onlyWhiteList = (bind.mainGetOptionSync(key: kOptionWhitelist)) !=
defaultOptionWhitelist;
_onlyWhiteList = whitelistNotEmpty();
_enableDirectIPAccess = option2bool(
kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer));
_enableRecordSession = option2bool(kOptionEnableRecordSession,
@@ -109,8 +110,20 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
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);
}
() async {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) async {
var update = false;
if (_hasIgnoreBattery) {
@@ -169,7 +182,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
if (update) {
setState(() {});
}
}();
});
}
@override
@@ -233,18 +246,76 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
],
));
final List<AbstractSettingsTile> enhancementsTiles = [];
final List<AbstractSettingsTile> shareScreenTiles = [
final enable2fa = bind.mainHasValid2FaSync();
final List<AbstractSettingsTile> tfaTiles = [
SettingsTile.switchTile(
title: Text(translate('enable-2fa-title')),
initialValue: bind.mainHasValid2FaSync(),
onToggle: (_) async {
initialValue: enable2fa,
onToggle: (v) async {
update() async {
setState(() {});
}
change2fa(callback: update);
if (v == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-2fa-confirm-tip'), () {
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(
title: Text(translate('Deny LAN discovery')),
initialValue: _denyLANDiscovery,
@@ -273,9 +344,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
initialValue: _onlyWhiteList,
onToggle: (_) async {
update() async {
final onlyWhiteList =
(await bind.mainGetOption(key: kOptionWhitelist)) !=
defaultOptionWhitelist;
final onlyWhiteList = whitelistNotEmpty();
if (onlyWhiteList != _onlyWhiteList) {
setState(() {
_onlyWhiteList = onlyWhiteList;
@@ -530,6 +599,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
));
final disabledSettings = bind.isDisableSettings();
final hideSecuritySettings =
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) == 'Y';
final settings = SettingsList(
sections: [
customClientSection,
@@ -553,13 +624,20 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
],
),
SettingsSection(title: Text(translate("Settings")), tiles: [
if (!disabledSettings)
if (!disabledSettings && !_hideNetwork && !_hideServer)
SettingsTile(
title: Text(translate('ID/Relay Server')),
leading: Icon(Icons.cloud),
onPressed: (context) {
showServerSettings(gFFI.dialogManager);
}),
if (!isIOS && !_hideNetwork && !_hideProxy)
SettingsTile(
title: Text(translate('Socks5/Http(s) Proxy')),
leading: Icon(Icons.network_ping),
onPressed: (context) {
changeSocks5Proxy();
}),
SettingsTile(
title: Text(translate('Language')),
leading: Icon(Icons.translate),
@@ -625,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(
title: Text(translate("Share Screen")),
tiles: shareScreenTiles,
),
if (!bind.isIncomingOnly()) defaultDisplaySection(),
if (isAndroid && !disabledSettings && !outgoingOnly)
if (isAndroid &&
!disabledSettings &&
!outgoingOnly &&
!hideSecuritySettings)
SettingsSection(
title: Text(translate("Enhancements")),
tiles: enhancementsTiles,
@@ -786,7 +875,7 @@ void showThemeSettings(OverlayDialogManager dialogManager) async {
void showAbout(OverlayDialogManager dialogManager) {
dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Text('${translate('About')} RustDesk'),
title: Text(translate('About RustDesk')),
content: Wrap(direction: Axis.vertical, spacing: 12, children: [
Text('Version: $version'),
InkWell(
@@ -940,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 {
final String label;
final String value;

View File

@@ -205,14 +205,15 @@ void showServerSettingsWithValue(
)
] +
[
TextFormField(
controller: relayCtrl,
decoration: InputDecoration(
labelText: translate('Relay Server'),
errorText: relayServerMsg.value.isEmpty
? null
: relayServerMsg.value),
)
if (isAndroid)
TextFormField(
controller: relayCtrl,
decoration: InputDecoration(
labelText: translate('Relay Server'),
errorText: relayServerMsg.value.isEmpty
? null
: relayServerMsg.value),
)
] +
[
TextFormField(

View File

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

View File

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

View File

@@ -235,13 +235,14 @@ class ChatModel with ChangeNotifier {
}
}
_isChatOverlayHide() => ((!isDesktop && chatIconOverlayEntry == null) ||
chatWindowOverlayEntry == null);
_isChatOverlayHide() =>
((!(isDesktop || isWebDesktop) && chatIconOverlayEntry == null) ||
chatWindowOverlayEntry == null);
toggleChatOverlay({Offset? chatInitPos}) {
if (_isChatOverlayHide()) {
gFFI.invokeMethod("enable_soft_keyboard", true);
if (!isDesktop) {
if (!(isDesktop || isWebDesktop)) {
showChatIconOverlay();
}
showChatWindowOverlay(chatInitPos: chatInitPos);

View File

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

View File

@@ -3,9 +3,12 @@ import 'dart:convert';
import 'package:flutter/material.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:get/get.dart';
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 'model.dart';
@@ -33,6 +36,7 @@ class JobID {
}
typedef GetSessionID = SessionID Function();
typedef GetDialogManager = OverlayDialogManager? Function();
class FileModel {
final WeakReference<FFI> parent;
@@ -44,13 +48,15 @@ class FileModel {
late final FileController remoteController;
late final GetSessionID getSessionID;
late final GetDialogManager getDialogManager;
SessionID get sessionId => getSessionID();
late final FileDialogEventLoop evtLoop;
FileModel(this.parent) {
getSessionID = () => parent.target!.sessionId;
getDialogManager = () => parent.target?.dialogManager;
fileFetcher = FileFetcher(getSessionID);
jobController = JobController(getSessionID);
jobController = JobController(getSessionID, getDialogManager);
localController = FileController(
isLocal: true,
getSessionID: getSessionID,
@@ -70,7 +76,7 @@ class FileModel {
Future<void> onReady() async {
await evtLoop.onReady();
await localController.onReady();
if (!isWeb) await localController.onReady();
await remoteController.onReady();
}
@@ -82,7 +88,7 @@ class FileModel {
}
Future<void> refreshAll() async {
await localController.refresh();
if (!isWeb) await localController.refresh();
await remoteController.refresh();
}
@@ -224,6 +230,33 @@ class FileModel {
);
}, 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 {
@@ -450,7 +483,7 @@ class FileController {
final isWindows = otherSideData.options.isWindows;
final showHidden = otherSideData.options.showHidden;
for (var from in items.items) {
final jobID = jobController.add(from, isRemoteToLocal);
final jobID = jobController.addTransferJob(from, isRemoteToLocal);
bind.sessionSendFiles(
sessionId: sessionId,
actId: jobID,
@@ -458,7 +491,8 @@ class FileController {
to: PathUtil.join(toPath, from.name, isWindows),
fileNum: 0,
includeHidden: showHidden,
isRemote: isRemoteToLocal);
isRemote: isRemoteToLocal,
isDir: from.isDirectory);
debugPrint(
"path: ${from.path}, toPath: $toPath, to: ${PathUtil.join(toPath, from.name, isWindows)}");
}
@@ -485,7 +519,7 @@ class FileController {
} else if (item.isDirectory) {
title = translate("Not an empty directory");
dialogManager?.showLoading(translate("Waiting"));
final fd = await fileFetcher.fetchDirectoryRecursive(
final fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
jobID, item.path, items.isLocal, true);
if (fd.path.isEmpty) {
fd.path = item.path;
@@ -493,13 +527,21 @@ class FileController {
fd.format(isWindows);
dialogManager?.dismissAll();
if (fd.entries.isEmpty) {
var deleteJobId = jobController.addDeleteDirJob(item, !isLocal, 0);
final confirm = await showRemoveDialog(
translate(
"Are you sure you want to delete this empty directory?"),
item.name,
false);
if (confirm == true) {
sendRemoveEmptyDir(item.path, 0);
sendRemoveEmptyDir(
item.path,
0,
deleteJobId,
);
} else {
jobController.updateJobStatus(deleteJobId,
error: "cancel", state: JobState.done);
}
return;
}
@@ -507,6 +549,13 @@ class FileController {
} else {
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++) {
final dirShow = item.isDirectory
@@ -521,24 +570,32 @@ class FileController {
);
try {
if (confirm == true) {
sendRemoveFile(entries[i].path, i);
sendRemoveFile(entries[i].path, i, deleteJobId);
final res = await jobController.jobResultListener.start();
// handle remove res;
if (item.isDirectory &&
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 (confirm == true) {
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();
if (item.isDirectory &&
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;
}
@@ -617,22 +674,19 @@ class FileController {
}, useAnimation: false);
}
void sendRemoveFile(String path, int fileNum) {
void sendRemoveFile(String path, int fileNum, int actId) {
bind.sessionRemoveFile(
sessionId: sessionId,
actId: JobController.jobID.next(),
actId: actId,
path: path,
isRemote: !isLocal,
fileNum: fileNum);
}
void sendRemoveEmptyDir(String path, int fileNum) {
void sendRemoveEmptyDir(String path, int fileNum, int actId) {
history.removeWhere((element) => element.contains(path));
bind.sessionRemoveAllEmptyDirs(
sessionId: sessionId,
actId: JobController.jobID.next(),
path: path,
isRemote: !isLocal);
sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
}
Future<void> createDir(String path) async {
@@ -642,29 +696,102 @@ class FileController {
path: path,
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 {
static final JobID jobID = JobID();
final jobTable = List<JobProgress>.empty(growable: true).obs;
final jobResultListener = JobResultListener<Map<String, dynamic>>();
final GetSessionID getSessionID;
final GetDialogManager getDialogManager;
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) {
return jobTable.indexWhere((element) => element.id == id);
}
// JobProgress? getJob(int id) {
// return jobTable.firstWhere((element) => element.id == id);
// }
// return jobID
int add(Entry from, bool isRemoteToLocal) {
int addTransferJob(Entry from, bool isRemoteToLocal) {
final jobID = JobController.jobID.next();
jobTable.add(JobProgress()
..type = JobType.transfer
..fileName = path.basename(from.path)
..jobName = from.path
..totalSize = from.size
@@ -674,6 +801,33 @@ class JobController {
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) {
try {
int id = int.parse(evt['id']);
@@ -684,7 +838,7 @@ class JobController {
job.fileNum = int.parse(evt['file_num']);
job.speed = double.parse(evt['speed']);
job.finishedSize = int.parse(evt['finished_size']);
debugPrint("update job $id with $evt");
job.recvJobRes = true;
jobTable.refresh();
}
} 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) {
jobResultListener.complete(evt);
return;
// return;
}
int id = int.parse(evt['id']);
int id = -1;
int? fileNum = 0;
double? speed = 0;
try {
id = int.parse(evt['id']);
} catch (_) {}
final jobIndex = getJob(id);
if (jobIndex != -1) {
final job = jobTable[jobIndex];
job.finishedSize = job.totalSize;
if (jobIndex == -1) return true;
final job = jobTable[jobIndex];
job.recvJobRes = true;
if (job.type == JobType.deleteFile) {
job.state = JobState.done;
job.fileNum = int.parse(evt['file_num']);
jobTable.refresh();
} 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();
if (job.type == JobType.deleteDir) {
return job.state == JobState.done;
} else {
return true;
}
}
@@ -716,16 +898,61 @@ class JobController {
final job = jobTable[jobIndex];
job.state = JobState.error;
job.err = err;
job.fileNum = int.parse(evt['file_num']);
if (err == "skipped") {
job.state = JobState.done;
job.finishedSize = job.totalSize;
job.recvJobRes = true;
if (job.type == JobType.transfer) {
int? fileNum = int.tryParse(evt['file_num']);
if (fileNum != null) job.fileNum = fileNum;
if (err == "skipped") {
job.state = JobState.done;
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();
}
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");
}
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 {
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
}
@@ -742,6 +969,7 @@ class JobController {
final currJobId = JobController.jobID.next();
String fileName = path.basename(isRemote ? remote : to);
var jobProgress = JobProgress()
..type = JobType.transfer
..fileName = fileName
..jobName = isRemote ? remote : to
..id = currJobId
@@ -917,11 +1145,11 @@ class FileFetcher {
}
}
Future<FileDirectory> fetchDirectoryRecursive(
Future<FileDirectory> fetchDirectoryRecursiveToRemove(
int actID, String path, bool isLocal, bool showHidden) async {
// TODO test Recursive is show hidden default?
try {
await bind.sessionReadDirRecursive(
await bind.sessionReadDirToRemoveRecursive(
sessionId: sessionId,
actId: actID,
path: path,
@@ -1016,8 +1244,12 @@ extension JobStateDisplay on JobState {
}
}
enum JobType { none, transfer, deleteFile, deleteDir }
class JobProgress {
JobType type = JobType.none;
JobState state = JobState.none;
var recvJobRes = false;
var id = 0;
var fileNum = 0;
var speed = 0.0;
@@ -1037,7 +1269,9 @@ class JobProgress {
int lastTransferredSize = 0;
clear() {
type = JobType.none;
state = JobState.none;
recvJobRes = false;
id = 0;
fileNum = 0;
speed = 0;
@@ -1051,11 +1285,81 @@ class JobProgress {
}
String display() {
if (state == JobState.done && err == "skipped") {
return translate("Skipped");
if (type == JobType.transfer) {
if (state == JobState.done && err == "skipped") {
return translate("Skipped");
}
} else if (type == JobType.deleteFile) {
if (err == "cancel") {
return translate("Cancel");
}
}
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 {
@@ -1083,6 +1387,13 @@ class PathUtil {
final pathUtil = isWindows ? windowsContext : posixContext;
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 {

View File

@@ -23,11 +23,19 @@ class GroupModel {
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 {
if (bind.isDisableGroupPanel()) return;
if (!gFFI.userModel.isLogin || groupLoading.value) return;
if (gFFI.userModel.networkError.isNotEmpty) return;
if (!force && initialized) return;
if (!quiet) {
groupLoading.value = true;

View File

@@ -177,7 +177,7 @@ class PointerEventToRust {
}
}
class ToReleaseKeys {
class ToReleaseRawKeys {
RawKeyEvent? lastLShiftKeyEvent;
RawKeyEvent? lastRShiftKeyEvent;
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 {
final WeakReference<FFI> parent;
String keyboardMode = '';
@@ -292,6 +334,7 @@ class InputModel {
var alt = false;
var command = false;
final ToReleaseRawKeys toReleaseRawKeys = ToReleaseRawKeys();
final ToReleaseKeys toReleaseKeys = ToReleaseKeys();
// 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) {
if (isViewOnly) return KeyEventResult.handled;
if ((isDesktop || isWebDesktop) && !isInputSourceFlutter) {
return KeyEventResult.handled;
if (!isInputSourceFlutter) {
if (isDesktop) {
return KeyEventResult.handled;
} else if (isWeb) {
return KeyEventResult.ignored;
}
}
final key = e.logicalKey;
@@ -358,7 +490,7 @@ class InputModel {
command = true;
}
}
toReleaseKeys.updateKeyDown(key, e);
toReleaseRawKeys.updateKeyDown(key, e);
}
if (e is RawKeyUpEvent) {
if (key == LogicalKeyboardKey.altLeft ||
@@ -376,12 +508,50 @@ class InputModel {
command = false;
}
toReleaseKeys.updateKeyUp(key, e);
toReleaseRawKeys.updateKeyUp(key, e);
}
// * Currently mobile does not enable map mode
if ((isDesktop || isWebDesktop) && keyboardMode == 'map') {
mapKeyboardMode(e);
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
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 {
legacyKeyboardMode(e);
}
@@ -389,7 +559,33 @@ class InputModel {
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 platformCode = -1;
bool down;
@@ -441,7 +637,7 @@ class InputModel {
.contains(KeyboardLockMode.scrollLock)) {
lockModes |= (1 << scrolllock);
}
bind.sessionHandleFlutterKeyEvent(
bind.sessionHandleFlutterRawKeyEvent(
sessionId: sessionId,
name: name,
platformCode: platformCode,
@@ -450,7 +646,7 @@ class InputModel {
downOrUp: down);
}
void legacyKeyboardMode(RawKeyEvent e) {
void legacyKeyboardModeRaw(RawKeyEvent e) {
if (e is RawKeyDownEvent) {
if (e.repeat) {
sendRawKey(e, press: true);
@@ -471,6 +667,24 @@ class InputModel {
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.
/// [down] indicates the key's state(down or up).
/// [press] indicates a click event(down and up).
@@ -566,7 +780,8 @@ class InputModel {
}
void enterOrLeave(bool enter) {
toReleaseKeys.release(handleRawKeyEvent);
toReleaseKeys.release(handleKeyEvent);
toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false;
// Fix status
@@ -577,6 +792,9 @@ class InputModel {
if (!isInputSourceFlutter) {
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
}
if (!isWeb && enter) {
bind.setCurSessionId(sessionId: sessionId);
}
}
/// Send mouse movement event with distance in [x] and [y].
@@ -1152,4 +1370,27 @@ class InputModel {
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:ui' as ui;
import 'package:bot_toast/bot_toast.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/peers_view.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/ab_model.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/cm_file_model.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_hbb/models/group_model.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/user_model.dart';
@@ -192,10 +195,10 @@ class FfiModel with ChangeNotifier {
_permissions[k] = v == 'true';
});
// Only inited at remote page
if (desktopType == DesktopType.remote) {
if (parent.target?.connType == ConnType.defaultConn) {
KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false;
}
debugPrint('$_permissions');
debugPrint('updatePermission: $_permissions');
notifyListeners();
}
@@ -267,6 +270,8 @@ class FfiModel with ChangeNotifier {
var name = evt['name'];
if (name == 'msgbox') {
handleMsgBox(evt, sessionId, peerId);
} else if (name == 'toast') {
handleToast(evt, sessionId, peerId);
} else if (name == 'set_multiple_windows_session') {
handleMultipleWindowsSession(evt, sessionId, peerId);
} else if (name == 'peer_info') {
@@ -304,8 +309,13 @@ class FfiModel with ChangeNotifier {
} else if (name == 'job_progress') {
parent.target?.fileModel.jobController.tryUpdateJobProgress(evt);
} else if (name == 'job_done') {
parent.target?.fileModel.jobController.jobDone(evt);
parent.target?.fileModel.refreshAll();
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();
}
} else if (name == 'job_error') {
parent.target?.fileModel.jobController.jobError(evt);
} else if (name == 'override_file_confirm') {
@@ -365,7 +375,7 @@ class FfiModel with ChangeNotifier {
} else if (name == 'plugin_option') {
handleOption(evt);
} else if (name == "sync_peer_hash_password_to_personal_ab") {
if (desktopType == DesktopType.main) {
if (desktopType == DesktopType.main || isWeb) {
final id = evt['id'];
final hash = evt['hash'];
if (id != null && hash != null) {
@@ -383,8 +393,12 @@ class FfiModel with ChangeNotifier {
handleFollowCurrentDisplay(evt, sessionId, peerId);
} else if (name == 'use_texture_render') {
_handleUseTextureRender(evt, sessionId, peerId);
} else if (name == "selected_files") {
if (isWeb) {
parent.target?.fileModel.onSelectedFiles(evt);
}
} 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) {
final running = evt['running'] == 'true';
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) {
@@ -506,10 +506,12 @@ class FfiModel with ChangeNotifier {
newDisplay.width = int.tryParse(evt['width']) ?? newDisplay.width;
newDisplay.height = int.tryParse(evt['height']) ?? newDisplay.height;
newDisplay.cursorEmbedded = int.tryParse(evt['cursor_embedded']) == 1;
newDisplay.originalWidth =
int.tryParse(evt['original_width']) ?? kInvalidResolutionValue;
newDisplay.originalHeight =
int.tryParse(evt['original_height']) ?? kInvalidResolutionValue;
newDisplay.originalWidth = int.tryParse(
evt['original_width'] ?? kInvalidResolutionValue.toString()) ??
kInvalidResolutionValue;
newDisplay.originalHeight = int.tryParse(
evt['original_height'] ?? kInvalidResolutionValue.toString()) ??
kInvalidResolutionValue;
newDisplay._scale = _pi.scaleOfDisplay(display);
_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].
showMsgBox(SessionID sessionId, String type, String title, String text,
String link, bool hasRetry, OverlayDialogManager dialogManager,
@@ -737,6 +770,8 @@ class FfiModel with ChangeNotifier {
/// Handle the peer info event based on [evt].
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.
parent.target?.inputModel.updateKeyboardMode();
@@ -800,7 +835,7 @@ class FfiModel with ChangeNotifier {
isRefreshing = false;
}
Map<String, dynamic> features = json.decode(evt['features']);
_pi.features.privacyMode = features['privacy_mode'] == 1;
_pi.features.privacyMode = features['privacy_mode'] == true;
if (!isCache) {
handleResolutions(peerId, evt["resolutions"]);
}
@@ -844,7 +879,7 @@ class FfiModel with ChangeNotifier {
for (final mode in [kKeyMapMode, kKeyLegacyMode]) {
if (bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: mode)) {
bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
await bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
break;
}
}
@@ -1015,14 +1050,15 @@ class FfiModel with ChangeNotifier {
// Notify to switch display
msgBox(sessionId, 'custom-nook-nocancel-hasclose-info', 'Prompt',
'display_is_plugged_out_msg', '', parent.target!.dialogManager);
final newDisplay = pi.primaryDisplay == kInvalidDisplayIndex
? 0
: pi.primaryDisplay;
final displays = newDisplay;
final isPeerPrimaryDisplayValid =
pi.primaryDisplay == kInvalidDisplayIndex ||
pi.primaryDisplay >= pi.displays.length;
final newDisplay =
isPeerPrimaryDisplayValid ? 0 : pi.primaryDisplay;
bind.sessionSwitchDisplay(
isDesktop: isDesktop,
sessionId: sessionId,
value: Int32List.fromList([displays]),
value: Int32List.fromList([newDisplay]),
);
if (_pi.isSupportMultiUiSession) {
@@ -1187,26 +1223,49 @@ class ImageModel with ChangeNotifier {
addCallbackOnFirstImage(Function(String) cb) => callbacksOnFirstImage.add(cb);
onRgba(int display, Uint8List rgba) {
clearImage() => _image = null;
bool _webDecodingRgba = false;
final List<Uint8List> _webRgbaList = List.empty(growable: true);
webOnRgba(int display, Uint8List rgba) async {
// deep copy needed, otherwise "instantiateCodec failed: TypeError: Cannot perform Construct on a detached ArrayBuffer"
_webRgbaList.add(Uint8List.fromList(rgba));
if (_webDecodingRgba) {
return;
}
_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);
img.decodeImageFromPixels(
rgba,
rect?.width.toInt() ?? 0,
rect?.height.toInt() ?? 0,
isWeb ? ui.PixelFormat.rgba8888 : ui.PixelFormat.bgra8888,
onPixelsCopied: () {
// Unlock the rgba memory from rust codes.
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');
}
});
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 {
@@ -1740,7 +1799,7 @@ class PredefinedCursor {
_image2 = img2.decodePng(base64Decode(png));
if (_image2 != null) {
// 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);
}
@@ -2187,6 +2246,7 @@ class CursorModel with ChangeNotifier {
debugPrint("deleting cursor with key $k");
deleteCustomCursor(k);
}
resetSystemCursor();
}
trySetRemoteWindowCoords() {
@@ -2233,8 +2293,10 @@ class QualityMonitorModel with ChangeNotifier {
updateQualityStatus(Map<String, dynamic> evt) {
try {
if ((evt['speed'] as String).isNotEmpty) _data.speed = evt['speed'];
if ((evt['fps'] as String).isNotEmpty) {
if (evt.containsKey('speed') && (evt['speed'] 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 pi = parent.target?.ffiModel.pi;
if (pi != null) {
@@ -2255,14 +2317,18 @@ class QualityMonitorModel with ChangeNotifier {
_data.fps = null;
}
}
if ((evt['delay'] as String).isNotEmpty) _data.delay = evt['delay'];
if ((evt['target_bitrate'] as String).isNotEmpty) {
if (evt.containsKey('delay') && (evt['delay'] as String).isNotEmpty) {
_data.delay = evt['delay'];
}
if (evt.containsKey('target_bitrate') &&
(evt['target_bitrate'] as String).isNotEmpty) {
_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'];
}
if ((evt['chroma'] as String).isNotEmpty) {
if (evt.containsKey('chroma') && (evt['chroma'] as String).isNotEmpty) {
_data.chroma = evt['chroma'];
}
notifyListeners();
@@ -2392,6 +2458,9 @@ class FFI {
late final ElevationModel elevationModel; // session
late final CmFileModel cmFileModel; // cm
late final TextureModel textureModel; //session
late final Peers recentPeersModel; // global
late final Peers favoritePeersModel; // global
late final Peers lanPeersModel; // global
FFI(SessionID? sId) {
sessionId = sId ?? (isDesktop ? Uuid().v4obj() : _constSessionId);
@@ -2412,6 +2481,16 @@ class FFI {
elevationModel = ElevationModel(WeakReference(this));
cmFileModel = CmFileModel(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
@@ -2506,6 +2585,7 @@ class FFI {
onEvent2UIRgba();
imageModel.onRgba(display, data);
});
this.id = id;
return;
}
@@ -2561,32 +2641,30 @@ class FFI {
}
} else if (message is EventToUI_Rgba) {
final display = message.field0;
if (imageModel.useTextureRender || ffiModel.pi.forceTextureRender) {
//debugPrint("EventToUI_Rgba display:$display");
textureModel.setTextureType(display: display, gpuTexture: false);
// Fetch the image buffer from rust codes.
final sz = platformFFI.getRgbaSize(sessionId, display);
if (sz == 0) {
platformFFI.nextRgba(sessionId, display);
return;
}
final rgba = platformFFI.getRgba(sessionId, display, sz);
if (rgba != null) {
onEvent2UIRgba();
await imageModel.onRgba(display, rgba);
} else {
// Fetch the image buffer from rust codes.
final sz = platformFFI.getRgbaSize(sessionId, display);
if (sz == 0) {
platformFFI.nextRgba(sessionId, display);
return;
}
final rgba = platformFFI.getRgba(sessionId, display, sz);
if (rgba != null) {
onEvent2UIRgba();
imageModel.onRgba(display, rgba);
} else {
platformFFI.nextRgba(sessionId, display);
}
platformFFI.nextRgba(sessionId, display);
}
} else if (message is EventToUI_Texture) {
final display = message.field0;
debugPrint("EventToUI_Texture display:$display");
if (hasGpuTextureRender) {
textureModel.setTextureType(display: display, gpuTexture: true);
onEvent2UIRgba();
final gpuTexture = message.field1;
debugPrint(
"EventToUI_Texture display:$display, gpuTexture:$gpuTexture");
if (gpuTexture && !hasGpuTextureRender) {
debugPrint('the gpuTexture is not supported.');
return;
}
textureModel.setTextureType(display: display, gpuTexture: gpuTexture);
onEvent2UIRgba();
}
}();
});
@@ -2622,8 +2700,9 @@ class FFI {
remember: remember);
}
void send2FA(SessionID sessionId, String code) {
bind.sessionSend2Fa(sessionId: sessionId, code: code);
void send2FA(SessionID sessionId, String code, bool trustThisDevice) {
bind.sessionSend2Fa(
sessionId: sessionId, code: code, trustThisDevice: trustThisDevice);
}
/// Close the remote session.
@@ -2640,7 +2719,7 @@ class FFI {
canvasModel.scale,
ffiModel.pi.currentDisplay);
}
imageModel.update(null);
await imageModel.update(null);
cursorModel.clear();
ffiModel.clear();
canvasModel.clear();

View File

@@ -48,6 +48,12 @@ class PlatformFFI {
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 {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
return packageInfo.version;
@@ -117,9 +123,13 @@ class PlatformFFI {
? DynamicLibrary.open('librustdesk.so')
: isWindows
? DynamicLibrary.open('librustdesk.dll')
: isMacOS
? DynamicLibrary.open("liblibrustdesk.dylib")
: DynamicLibrary.process();
:
// Use executable itself as the dynamic library for MacOS.
// 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');
try {
_session_get_rgba = dylib.lookupFunction<F3Dart, F3>("session_get_rgba");
@@ -272,4 +282,6 @@ class PlatformFFI {
void syncAndroidServiceAppDirConfigPath() {
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
}
void setFullscreenCallback(void Function(bool) fun) {}
}

View File

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

View File

@@ -152,7 +152,7 @@ class PeerTabModel with ChangeNotifier {
// 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,
// because onTap is delayed when onDoubleTap is not null
if (isDesktop && !_isShiftDown) return;
if (isDesktop || isWebDesktop) return;
_multiSelectionMode = true;
}
final cached = _currentTabCachedPeers.map((e) => e.id).toList();
@@ -184,10 +184,17 @@ class PeerTabModel with ChangeNotifier {
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) {
Future.delayed(Duration.zero, () {
final isPreEmpty = _currentTabCachedPeers.isEmpty;
_currentTabCachedPeers = peers;
notifyListeners();
final isNowEmpty = _currentTabCachedPeers.isEmpty;
if (isPreEmpty != isNowEmpty) {
notifyListeners();
}
});
}

View File

@@ -6,3 +6,11 @@ final platformFFI = PlatformFFI.instance;
final localeName = PlatformFFI.localeName;
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

@@ -177,6 +177,11 @@ class ServerModel with ChangeNotifier {
await timerCallback();
});
}
// Initial keyboard status is off on mobile
if (isMobile) {
bind.mainSetOption(key: kOptionEnableKeyboard, value: 'N');
}
}
/// 1. check android permission
@@ -191,7 +196,7 @@ class ServerModel with ChangeNotifier {
bind.mainSetOption(key: kOptionEnableAudio, value: "N");
} else {
final audioOption = await bind.mainGetOption(key: kOptionEnableAudio);
_audioOk = audioOption.isEmpty;
_audioOk = audioOption != 'N';
}
// file
@@ -201,7 +206,7 @@ class ServerModel with ChangeNotifier {
} else {
final fileOption =
await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption.isEmpty;
_fileOk = fileOption != 'N';
}
notifyListeners();
@@ -821,7 +826,7 @@ class Client {
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['is_start'] = authorized;
data['authorized'] = authorized;
data['is_file_transfer'] = isFileTransfer;
data['port_forward'] = portForward;
data['name'] = name;
@@ -835,6 +840,8 @@ class Client {
data['block_input'] = blockInput;
data['disconnected'] = disconnected;
data['from_switch'] = fromSwitch;
data['in_voice_call'] = inVoiceCall;
data['incoming_voice_call'] = incomingVoiceCall;
return data;
}

View File

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

View File

@@ -17,13 +17,23 @@ bool refreshingUser = false;
class UserModel {
final RxString userName = ''.obs;
final RxBool isAdmin = false.obs;
final RxString networkError = ''.obs;
bool get isLogin => userName.isNotEmpty;
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 {
if (bind.isDisableAccount()) return;
networkError.value = '';
final token = bind.mainGetLocalOption(key: 'access_token');
if (token == '') {
await updateOtherModels();
@@ -38,12 +48,18 @@ class UserModel {
if (refreshingUser) return;
try {
refreshingUser = true;
final response = await http.post(Uri.parse('$url/api/currentUser'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token'
},
body: json.encode(body));
final http.Response response;
try {
response = await http.post(Uri.parse('$url/api/currentUser'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token'
},
body: json.encode(body));
} catch (e) {
networkError.value = e.toString();
rethrow;
}
refreshingUser = false;
final status = response.statusCode;
if (status == 401 || status == 400) {

View File

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

View File

@@ -11,3 +11,7 @@ final isWebDesktop_ = false;
final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
String get screenInfo_ => '';
final isWebOnWindows_ = false;
final isWebOnLinux_ = false;
final isWebOnMacOS_ = false;

View File

@@ -9,6 +9,7 @@ import 'package:flutter_hbb/models/model.dart';
deleteCustomCursor(String key) =>
custom_cursor_manager.CursorManager.instance.deleteCursor(key);
resetSystemCursor() {}
MouseCursor buildCursorOfCache(
CursorModel cursor, double scale, CursorData? cache) {

View File

@@ -13,14 +13,12 @@ Future<ui.Image?> decodeImageFromPixels(
int? rowBytes,
int? targetWidth,
int? targetHeight,
VoidCallback? onPixelsCopied, // must ensure onPixelsCopied is called no matter this function succeeds
bool allowUpscaling = true,
}) async {
if (targetWidth != null) {
assert(allowUpscaling || targetWidth <= width);
if (!(allowUpscaling || targetWidth <= width)) {
print("not allow upscaling but targetWidth > width");
onPixelsCopied?.call();
return null;
}
}
@@ -28,7 +26,6 @@ Future<ui.Image?> decodeImageFromPixels(
assert(allowUpscaling || targetHeight <= height);
if (!(allowUpscaling || targetHeight <= height)) {
print("not allow upscaling but targetHeight > height");
onPixelsCopied?.call();
return null;
}
}
@@ -36,9 +33,7 @@ Future<ui.Image?> decodeImageFromPixels(
final ui.ImmutableBuffer buffer;
try {
buffer = await ui.ImmutableBuffer.fromUint8List(pixels);
onPixelsCopied?.call();
} catch (e) {
onPixelsCopied?.call();
return null;
}

View File

@@ -124,6 +124,9 @@ class RustDeskMultiWindowManager {
bool withScreenRect,
) async {
final windowController = await DesktopMultiWindow.createWindow(msg);
if (isWindows) {
windowController.setInitBackgroundColor(Colors.black);
}
final windowId = windowController.windowId;
if (!withScreenRect) {
windowController

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
import 'dart:js' as js;
import 'dart:html' as html;
// cycle imports, maybe we can improve this
import 'package:flutter_hbb/consts.dart';
final isAndroid_ = false;
final isIOS_ = false;
@@ -11,3 +14,8 @@ final isWebDesktop_ = !js.context.callMethod('isMobile');
final isDesktop_ = false;
String get screenInfo_ => js.context.callMethod('getByName', ['screen_info']);
final _localOs = js.context.callMethod('getByName', ['local_os', '']);
final isWebOnWindows_ = _localOs == kPeerPlatformWindows;
final isWebOnLinux_ = _localOs == kPeerPlatformLinux;
final isWebOnMacOS_ = _localOs == kPeerPlatformMacOS;

View File

@@ -58,6 +58,11 @@ class CursorManager {
]);
}
}
Future<void> resetSystemCursor() async {
latestKey = '';
js.context.callMethod('setByName', ['cursor', 'auto']);
}
}
class FlutterCustomMemoryImageCursor extends MouseCursor {
@@ -92,6 +97,7 @@ class _FlutterCustomMemoryImageCursorSession extends MouseCursorSession {
}
deleteCustomCursor(String key) => CursorManager.instance.deleteCursor(key);
resetSystemCursor() => CursorManager.instance.resetSystemCursor();
MouseCursor buildCursorOfCache(
model.CursorModel cursor, double scale, model.CursorData? cache) {

View File

@@ -0,0 +1,14 @@
Future<void> webselectFiles({required bool is_folder}) async {
throw UnimplementedError("webselectFiles");
}
Future<void> webSendLocalFiles(
{required int handleIndex,
required int actId,
required String path,
required String to,
required int fileNum,
required bool includeHidden,
required bool isRemote}) {
throw UnimplementedError("webSendLocalFiles");
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart';
class WebSettingsPage extends StatelessWidget {
const WebSettingsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return _buildDesktopButton(context);
}
Widget _buildDesktopButton(BuildContext context) {
return IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) =>
DesktopSettingPage(initialTabkey: SettingsTabKey.general),
),
);
},
);
}
}

View File

@@ -6,7 +6,7 @@ class TextureRgbaRenderer {
}
Future<bool> closeTexture(int key) {
throw UnimplementedError();
return Future(() => true);
}
Future<bool> onRgba(

View File

@@ -0,0 +1,30 @@
import 'dart:async';
import 'dart:convert';
import 'dart:js' as js;
Future<void> webselectFiles({required bool is_folder}) async {
return Future(
() => js.context.callMethod('setByName', ['select_files', is_folder]));
}
Future<void> webSendLocalFiles(
{required int handleIndex,
required int actId,
required String path,
required String to,
required int fileNum,
required bool includeHidden,
required bool isRemote}) {
return Future(() => js.context.callMethod('setByName', [
'send_local_files',
jsonEncode({
'id': actId,
'handle_index': handleIndex,
'path': path,
'to': to,
'file_num': fileNum,
'include_hidden': includeHidden,
'is_remote': isRemote,
})
]));
}

View File

@@ -95,17 +95,17 @@ SPEC CHECKSUMS:
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
file_selector_macos: 54fdab7caa3ac3fc43c9fac4d7d8d231277f8cf2
flutter_custom_cursor: 629957115075c672287bd0fa979d863ccf6024f7
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
texture_rgba_renderer: cbed959a3c127122194a364e14b8577bd62dc8f2
uni_links_desktop: 45900fb319df48fcdea2df0756e9c2626696b026
url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95
video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579
url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
window_size: 339dafa0b27a95a62a843042038fa6c3c48de195

View File

@@ -45,5 +45,7 @@
<string>Record the sound from microphone for the purpose of the remote desktop.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>LSUIElement</key>
<string>1</string>
</dict>
</plist>

2
flutter/ndk_x86.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter

View File

@@ -335,7 +335,7 @@ packages:
description:
path: "."
ref: HEAD
resolved-ref: "60773827434eefe6d01eefa814dca9a032b970b3"
resolved-ref: "519350f1f40746798299e94786197d058353bac9"
url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window"
source: git
version: "0.1.0"
@@ -377,9 +377,25 @@ packages:
path: "."
ref: "24cb88413fa5181d949ddacbb30a65d5c459e7d9"
resolved-ref: "24cb88413fa5181d949ddacbb30a65d5c459e7d9"
url: "https://github.com/21pages/dynamic_layouts.git"
url: "https://github.com/rustdesk-org/dynamic_layouts.git"
source: git
version: "0.0.1+1"
extended_text:
dependency: "direct main"
description:
name: extended_text
sha256: "7f382de3af12992e34bd72ddd36becf90c4720900af126cb9859f0189af71ffe"
url: "https://pub.dev"
source: hosted
version: "13.0.0"
extended_text_library:
dependency: transitive
description:
name: extended_text_library
sha256: "55d09098ec56fab0d9a8a68950ca0bbf2efa1327937f7cec6af6dfa066234829"
url: "https://pub.dev"
source: hosted
version: "12.0.0"
external_path:
dependency: "direct main"
description:
@@ -509,9 +525,9 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "38951317afe79d953ab25733667bd96e172a80d3"
resolved-ref: "38951317afe79d953ab25733667bd96e172a80d3"
url: "https://github.com/21pages/flutter_gpu_texture_renderer"
ref: "2ded7f146437a761ffe6981e2f742038f85ca68d"
resolved-ref: "2ded7f146437a761ffe6981e2f742038f85ca68d"
url: "https://github.com/rustdesk-org/flutter_gpu_texture_renderer"
source: git
version: "0.0.1"
flutter_improved_scrolling:
@@ -1613,5 +1629,5 @@ packages:
source: hosted
version: "0.2.1"
sdks:
dart: ">=3.2.0 <4.0.0"
flutter: ">=3.16.0"
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.19.0"

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
version: 1.2.6+44
version: 1.3.2+51
environment:
sdk: '^3.1.0'
@@ -79,7 +79,7 @@ dependencies:
git:
url: https://github.com/rustdesk-org/flutter_improved_scrolling
uni_links: ^0.5.1
uni_links_desktop: ^0.1.7
uni_links_desktop: ^0.1.6 # use 0.1.6 to make flutter 3.13 works
path: ^1.8.1
auto_size_text: ^3.0.0
bot_toast: ^4.0.3
@@ -92,18 +92,19 @@ dependencies:
dropdown_button2: ^2.0.0
flutter_gpu_texture_renderer:
git:
url: https://github.com/21pages/flutter_gpu_texture_renderer
ref: 38951317afe79d953ab25733667bd96e172a80d3
url: https://github.com/rustdesk-org/flutter_gpu_texture_renderer
ref: 2ded7f146437a761ffe6981e2f742038f85ca68d
uuid: ^3.0.7
auto_size_text_field: ^2.2.1
flex_color_picker: ^3.3.0
dynamic_layouts:
git:
url: https://github.com/21pages/dynamic_layouts.git
url: https://github.com/rustdesk-org/dynamic_layouts.git
ref: 24cb88413fa5181d949ddacbb30a65d5c459e7d9
pull_down_button: ^0.9.3
device_info_plus: ^9.1.0
qr_flutter: ^4.1.0
extended_text: 13.0.0
dev_dependencies:
icons_launcher: ^2.0.4

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