Homepage: https://rustdesk.com
-Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
+Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1
Description: A remote control software.
@@ -512,7 +512,7 @@ def main():
system2('pip3 install -r requirements.txt')
system2(
f'python3 ./generate.py -f ../../{res_dir} -o . -e ../../{res_dir}/rustdesk-{version}-win7-install.exe')
- system2(f'mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
+ system2('mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
elif os.path.isfile('/usr/bin/pacman'):
# pacman -S -needed base-devel
system2("sed -i 's/pkgver=.*/pkgver=%s/g' res/PKGBUILD" % version)
diff --git a/build.rs b/build.rs
index 92fb1f4b4..672f972d9 100644
--- a/build.rs
+++ b/build.rs
@@ -18,7 +18,7 @@ fn build_mac() {
b.flag("-DNO_InputMonitoringAuthStatus=1");
}
}
- b.flag("-std=c++17").file(file).compile("macos");
+ b.file(file).compile("macos");
println!("cargo:rerun-if-changed={}", file);
}
diff --git a/docs/CODE_OF_CONDUCT-FR.md b/docs/CODE_OF_CONDUCT-FR.md
deleted file mode 100644
index dca61e0aa..000000000
--- a/docs/CODE_OF_CONDUCT-FR.md
+++ /dev/null
@@ -1,143 +0,0 @@
-
-# Code de conduite des contributeurs
-
-## Notre engagement
-
-En tant que membres, contributeurs et responsables, nous nous engageons à faire
-de la participation à notre communauté une expérience exempte de harcèlement pour
-tous, indépendamment de l'âge, de la taille corporelle, du handicap visible ou
-invisible, de l'origine ethnique, des caractéristiques sexuelles, de l'identité
-et de l'expression de genre, du niveau d'expérience, de l'éducation, du statut
-socio-économique, de la nationalité, de l'apparence personnelle, de la race, de
-la religion ou de l'identité et de l'orientation sexuelle.
-
-Nous nous engageons à agir et à interagir de manière à contribuer à une
-communauté ouverte, accueillante, diversifiée, inclusive et saine.
-
-## Nos standards
-
-Exemples de comportements qui contribuent à un environnement positif pour notre
-communauté :
-
-* Faire preuve d'empathie et de bienveillance envers les autres
-* Respecter les opinions, les points de vue et les expériences différents
-* Donner et accepter gracieusement les retours constructifs
-* Assumer ses responsabilités, s'excuser auprès des personnes affectées par nos
- erreurs et apprendre de l'expérience
-* Se concentrer sur ce qui est le mieux non seulement pour nous en tant
- qu'individus, mais pour l'ensemble de la communauté
-
-Exemples de comportements inacceptables :
-
-* L'utilisation de langage ou d'images à caractère sexuel, et les attentions ou
- avances sexuelles de quelque nature que ce soit
-* Le trolling, les commentaires insultants ou désobligeants, et les attaques
- personnelles ou politiques
-* Le harcèlement public ou privé
-* La publication d'informations privées d'autrui, telles qu'une adresse physique
- ou électronique, sans autorisation explicite
-* Tout autre comportement qui pourrait raisonnablement être considéré comme
- inapproprié dans un cadre professionnel
-
-## Responsabilités en matière d'application
-
-Les responsables de la communauté sont chargés de clarifier et d'appliquer nos
-standards de comportement acceptable et prendront des mesures correctives
-appropriées et équitables en réponse à tout comportement qu'ils jugent
-inapproprié, menaçant, offensant ou nuisible.
-
-Les responsables de la communauté ont le droit et la responsabilité de
-supprimer, modifier ou rejeter les commentaires, commits, code, modifications
-du wiki, issues et autres contributions qui ne sont pas conformes à ce Code de
-conduite, et communiqueront les raisons de leurs décisions de modération le cas
-échéant.
-
-## Portée
-
-Ce Code de conduite s'applique dans tous les espaces communautaires, et
-s'applique également lorsqu'une personne représente officiellement la communauté
-dans les espaces publics. Les exemples de représentation de notre communauté
-incluent l'utilisation d'une adresse e-mail officielle, la publication via un
-compte de réseau social officiel, ou le fait d'agir en tant que représentant
-désigné lors d'un événement en ligne ou hors ligne.
-
-## Application
-
-Les cas de comportements abusifs, harcelants ou autrement inacceptables peuvent
-être signalés aux responsables de la communauté chargés de l'application à
-[info@rustdesk.com](mailto:info@rustdesk.com).
-Toutes les plaintes seront examinées et feront l'objet d'une enquête rapide et
-équitable.
-
-Tous les responsables de la communauté sont tenus de respecter la vie privée et
-la sécurité de la personne ayant signalé un incident.
-
-## Directives d'application
-
-Les responsables de la communauté suivront ces Directives d'impact communautaire
-pour déterminer les conséquences de toute action qu'ils jugent en violation de ce
-Code de conduite :
-
-### 1. Correction
-
-**Impact communautaire** : Utilisation d'un langage inapproprié ou autre
-comportement jugé non professionnel ou indésirable dans la communauté.
-
-**Conséquence** : Un avertissement écrit et privé de la part des responsables de
-la communauté, expliquant la nature de la violation et pourquoi le comportement
-était inapproprié. Des excuses publiques peuvent être demandées.
-
-### 2. Avertissement
-
-**Impact communautaire** : Une violation par un incident isolé ou une série
-d'actions.
-
-**Conséquence** : Un avertissement avec des conséquences en cas de comportement
-répété. Aucune interaction avec les personnes impliquées, y compris les
-interactions non sollicitées avec les personnes chargées d'appliquer le Code de
-conduite, pendant une période déterminée. Cela inclut d'éviter les interactions
-dans les espaces communautaires ainsi que dans les canaux externes comme les
-réseaux sociaux. Le non-respect de ces conditions peut entraîner une exclusion
-temporaire ou permanente.
-
-### 3. Exclusion temporaire
-
-**Impact communautaire** : Une violation grave des standards communautaires, y
-compris un comportement inapproprié persistant.
-
-**Conséquence** : Une exclusion temporaire de toute interaction ou communication
-publique avec la communauté pendant une période déterminée. Aucune interaction
-publique ou privée avec les personnes impliquées, y compris les interactions non
-sollicitées avec les personnes chargées d'appliquer le Code de conduite, n'est
-autorisée pendant cette période. Le non-respect de ces conditions peut entraîner
-une exclusion permanente.
-
-### 4. Exclusion permanente
-
-**Impact communautaire** : Démontrer un schéma de violation des standards
-communautaires, y compris un comportement inapproprié persistant, le harcèlement
-d'une personne, ou une agression envers des catégories de personnes ou leur
-dénigrement.
-
-**Conséquence** : Une exclusion permanente de toute interaction publique au sein
-de la communauté.
-
-## Attribution
-
-Ce Code de conduite est adapté du [Contributor Covenant][homepage], version 2.0,
-disponible à l'adresse
-[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
-
-Les Directives d'impact communautaire ont été inspirées par
-[l'échelle d'application du code de conduite de Mozilla][Mozilla CoC].
-
-Pour des réponses aux questions fréquentes sur ce code de conduite, consultez la
-FAQ à l'adresse [https://www.contributor-covenant.org/faq][FAQ]. Des traductions
-sont disponibles à l'adresse
-[https://www.contributor-covenant.org/translations][translations].
-
-[homepage]: https://www.contributor-covenant.org
-[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
-[Mozilla CoC]: https://github.com/mozilla/diversity
-[FAQ]: https://www.contributor-covenant.org/faq
-[translations]: https://www.contributor-covenant.org/translations
diff --git a/docs/CONTRIBUTING-FR.md b/docs/CONTRIBUTING-FR.md
deleted file mode 100644
index 6f800de7d..000000000
--- a/docs/CONTRIBUTING-FR.md
+++ /dev/null
@@ -1,55 +0,0 @@
-
-# Contribuer à RustDesk
-
-RustDesk accueille les contributions de tous. Voici les directives si vous
-envisagez de nous aider :
-
-## Contributions
-
-Les contributions à RustDesk ou à ses dépendances doivent être soumises sous
-forme de pull requests GitHub. Chaque pull request sera examinée par un
-contributeur principal (une personne ayant la permission d'intégrer des
-correctifs) et sera soit intégrée dans la branche principale, soit accompagnée
-de retours sur les modifications requises. Toutes les contributions doivent
-suivre ce format, même celles des contributeurs principaux.
-
-Si vous souhaitez travailler sur une issue, veuillez d'abord la revendiquer en
-commentant sur l'issue GitHub indiquant que vous souhaitez la traiter. Cela
-permet d'éviter les efforts en double de la part des contributeurs sur la même
-issue.
-
-## Liste de vérification pour les pull requests
-
-- Partez de la branche master et, si nécessaire, effectuez un rebase sur la
- branche master actuelle avant de soumettre votre pull request. Si elle ne
- fusionne pas proprement avec master, il vous sera peut-être demandé de
- rebaser vos modifications.
-
-- Les commits doivent être aussi petits que possible, tout en s'assurant que
- chaque commit est correct de manière indépendante (c.-à-d. que chaque commit
- doit compiler et passer les tests).
-
-- Les commits doivent être accompagnés d'une signature Developer Certificate of
- Origin (http://developercertificate.org), indiquant que vous (et votre
- employeur le cas échéant) acceptez d'être liés par les termes de la
- [licence du projet](../LICENCE). Dans git, il s'agit de l'option `-s` de
- `git commit`.
-
-- Si votre correctif n'est pas examiné ou si vous avez besoin qu'une personne
- spécifique l'examine, vous pouvez @-mentionner un relecteur pour demander une
- revue dans la pull request ou un commentaire, ou vous pouvez demander une
- revue par [e-mail](mailto:info@rustdesk.com).
-
-- Ajoutez des tests relatifs au bug corrigé ou à la nouvelle fonctionnalité.
-
-Pour des instructions git spécifiques, consultez le
-[GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
-
-## Conduite
-
-https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
-
-## Communication
-
-Les contributeurs de RustDesk se retrouvent fréquemment sur
-[Discord](https://discord.gg/nDceKgxnkV).
diff --git a/docs/README-DE.md b/docs/README-DE.md
index ba8894411..c746e88d0 100644
--- a/docs/README-DE.md
+++ b/docs/README-DE.md
@@ -1,14 +1,15 @@
-
+
+ Server •
Kompilieren •
Docker •
Dateistruktur •
Screenshots
- [Українська ] | [česky ] | [中文 ] | [Magyar ] | [Español ] | [فارسی ] | [Français ] | [Deutsch ] | [Polski ] | [Indonesian ] | [Suomi ] | [മലയാളം ] | [日本語 ] | [Nederlands ] | [Italiano ] | [Русский ] | [Português (Brasil) ] | [Esperanto ] | [한국어 ] | [العربي ] | [Tiếng Việt ] | [Dansk ] | [Ελληνικά ] | [Türkçe ] | [Norsk ] | [Română ]
+ [English ] | [Українська ] | [česky ] | [中文 ] | [Magyar ] | [Español ] | [فارسی ] | [Français ] | [Polski ] | [Indonesian ] | [Suomi ] | [മലയാളം ] | [日本語 ] | [Nederlands ] | [Italiano ] | [Русский ] | [Português (Brasil) ] | [Esperanto ] | [한국어 ] | [العربي ] | [Tiếng Việt ] | [Dansk ] | [Ελληνικά ]
Wir brauchen Ihre Hilfe, um dieses README, die RustDesk-Benutzeroberfläche und die Dokumentation in Ihre Muttersprache zu übersetzen.
-> [!Caution]
+> [!Vorsicht]
> **Haftungsausschluss bei Missbrauch::**
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
@@ -27,14 +28,11 @@ RustDesk heißt jegliche Mitarbeit willkommen. Schauen Sie sich [CONTRIBUTING-DE
[**Programm herunterladen**](https://github.com/rustdesk/rustdesk/releases)
-[**Nightly Builds**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
+[**Nächtliche Erstellung**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
-[ ](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
-[ ](https://flathub.org/apps/com.rustdesk.RustDesk)
## Abhängigkeiten
@@ -66,19 +64,18 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
- libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
+ libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
-sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
+sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
-
### Fedora 28 (CentOS 8)
```sh
-sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
+sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
```
### Arch (Manjaro)
@@ -117,7 +114,7 @@ cd
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
-git clone --recurse-submodules https://github.com/rustdesk/rustdesk
+git clone https://github.com/rustdesk/rustdesk
cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
@@ -132,7 +129,6 @@ Beginnen Sie damit, das Repository zu klonen und den Docker-Container zu bauen:
```sh
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
-git submodule update --init --recursive
docker build -t "rustdesk-builder" .
```
@@ -161,7 +157,6 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: Bildschirmaufnahme
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: Plattformspezifische Maus- und Tastatursteuerung
-- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Datei kopieren und einfügen Implementierung für Windows, Linux, macOS.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: Audio/Zwischenablage/Eingabe/Videodienste und Netzwerkverbindungen
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Starten einer Peer-Verbindung
@@ -172,11 +167,10 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
## Screenshots
-
+
-
+
-
-
-
+
+
diff --git a/docs/README-FR.md b/docs/README-FR.md
index 345e53b58..c2e25886d 100644
--- a/docs/README-FR.md
+++ b/docs/README-FR.md
@@ -34,9 +34,9 @@ Les versions de bureau utilisent [sciter](https://sciter.com/) pour l'interface
- Installez [vcpkg](https://github.com/microsoft/vcpkg), et définissez correctement la variable d'environnement `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
+ - Linux/Osx : vcpkg install libvpx libyuv opus aom
-- Exécutez `cargo run`
+- Exécuter `cargo run`
## Comment compiler/build sous Linux
@@ -93,7 +93,7 @@ cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
mv libsciter-gtk.so target/debug
-cargo run
+Exécution du cargo
```
## Comment construire avec Docker
diff --git a/docs/README-PL.md b/docs/README-PL.md
index 437682a9c..2cb4123ea 100644
--- a/docs/README-PL.md
+++ b/docs/README-PL.md
@@ -13,9 +13,7 @@ Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](http
[](https://rustdesk.com/pricing.html)
-## O projekcie
-
-RustDesk to wieloplatformowe oprogramowanie do zdalnego pulpitu, napisane w języku Rust, zaprojektowane z myślą o prostocie wdrożenia, bezpieczeństwie i pełnej kontroli użytkownika nad danymi. Aplikacja działa od razu po uruchomieniu i nie wymaga skomplikowanej konfiguracji. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
+Kolejny program do zdalnego pulpitu, napisany w Rust. Działa od samego początku, nie wymaga konfiguracji. Masz pełną kontrolę nad swoimi danymi, bez obaw o bezpieczeństwo. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).

@@ -33,7 +31,7 @@ RustDesk zaprasza do współpracy każdego. Zobacz [`docs/CONTRIBUTING-PL.md`](C
## Zależności
-Wersje desktopowe korzystają z biblioteki [sciter](https://sciter.com/) jako silnika GUI. Bibliotekę Sciter należy pobrać i zainstalować samodzielnie.
+Wersje desktopowe używają [sciter](https://sciter.com/) dla GUI, proszę pobrać samodzielnie bibliotekę 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) |
diff --git a/docs/README-RU.md b/docs/README-RU.md
index 928faad07..ad12e9527 100644
--- a/docs/README-RU.md
+++ b/docs/README-RU.md
@@ -167,7 +167,7 @@ target/release/rustdesk
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
- **[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](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](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
diff --git a/docs/SECURITY-FR.md b/docs/SECURITY-FR.md
deleted file mode 100644
index 1cf2c6167..000000000
--- a/docs/SECURITY-FR.md
+++ /dev/null
@@ -1,16 +0,0 @@
-
-# Politique de sécurité
-
-## Signaler une vulnérabilité
-
-Nous accordons une très grande importance à la sécurité du projet. Nous
-encourageons tous les utilisateurs à nous signaler toute vulnérabilité qu'ils
-découvrent.
-
-Si vous trouvez une vulnérabilité de sécurité dans le projet RustDesk, veuillez
-la signaler de manière responsable en envoyant un e-mail à info@rustdesk.com.
-
-À ce stade, nous n'avons pas de programme de bug bounty. Nous sommes une petite
-équipe qui s'attaque à un grand défi. Nous vous encourageons vivement à signaler
-toute vulnérabilité de manière responsable afin que nous puissions continuer à
-développer une application sécurisée pour l'ensemble de la communauté.
diff --git a/flatpak/com.rustdesk.RustDesk.metainfo.xml b/flatpak/com.rustdesk.RustDesk.metainfo.xml
index 90bdafcb5..0d3b33bb8 100644
--- a/flatpak/com.rustdesk.RustDesk.metainfo.xml
+++ b/flatpak/com.rustdesk.RustDesk.metainfo.xml
@@ -18,7 +18,7 @@
Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs.
Own your data, easily set up self-hosting solution on your infrastructure.
P2P connection with end-to-end encryption based on NaCl.
- No administrative privileges or installation needed for Windows, elevate privilege locally or from remote on demand.
+ No administrative privileges or installation needed for Windows, elevate priviledge locally or from remote on demand.
We like to keep things simple and will strive to make simpler where possible.
@@ -56,4 +56,4 @@
pointing
-
+
\ No newline at end of file
diff --git a/flatpak/rustdesk.json b/flatpak/rustdesk.json
index 2418ac2a6..a99141f17 100644
--- a/flatpak/rustdesk.json
+++ b/flatpak/rustdesk.json
@@ -55,8 +55,8 @@
],
"finish-args": [
"--share=ipc",
- "--socket=wayland",
"--socket=x11",
+ "--socket=wayland",
"--share=network",
"--filesystem=home",
"--device=dri",
diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/AudioRecordHandle.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/AudioRecordHandle.kt
index 05742d7fd..db222dc84 100644
--- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/AudioRecordHandle.kt
+++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/AudioRecordHandle.kt
@@ -62,13 +62,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return false
}
}
- val recorder = try {
- builder.build()
- } catch (e: Exception) {
- Log.e(logTag, "createAudioRecorder failed", e)
- return false
- }
- audioRecorder = recorder
+ audioRecorder = builder.build()
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
return true
}
diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt
index 6dd4a2f61..696d536c6 100644
--- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt
+++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt
@@ -311,10 +311,7 @@ class FloatingWindowService : Service(), View.OnTouchListener {
popupMenu.menu.add(0, idSyncClipboard, 0, translate("Update client clipboard"))
}
val idStopService = 2
- val hideStopService = FFI.getBuildinOption("hide-stop-service") == "Y"
- if (!hideStopService) {
- popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
- }
+ popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
idShowRustDesk -> {
@@ -392,3 +389,4 @@ class FloatingWindowService : Service(), View.OnTouchListener {
return false
}
}
+
diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt
index e3c9d9830..8e9b39968 100644
--- a/flutter/android/app/src/main/kotlin/ffi.kt
+++ b/flutter/android/app/src/main/kotlin/ffi.kt
@@ -24,7 +24,6 @@ object FFI {
external fun setFrameRawEnable(name: String, value: Boolean)
external fun setCodecInfo(info: String)
external fun getLocalOption(key: String): String
- external fun getBuildinOption(key: String): String
external fun onClipboardUpdate(clips: ByteBuffer)
external fun isServiceClipboardEnabled(): Boolean
}
diff --git a/flutter/assets/auth-microsoft.svg b/flutter/assets/auth-microsoft.svg
deleted file mode 100644
index c9ce5f9cf..000000000
--- a/flutter/assets/auth-microsoft.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/flutter/assets/keyboard.svg b/flutter/assets/keyboard.svg
new file mode 100644
index 000000000..0e94a5a62
--- /dev/null
+++ b/flutter/assets/keyboard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/flutter/assets/keyboard_mouse.svg b/flutter/assets/keyboard_mouse.svg
deleted file mode 100644
index f6a5b4b2b..000000000
--- a/flutter/assets/keyboard_mouse.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/flutter/build_fdroid.sh b/flutter/build_fdroid.sh
index d50a6a6ce..ecfb444ef 100755
--- a/flutter/build_fdroid.sh
+++ b/flutter/build_fdroid.sh
@@ -7,7 +7,7 @@
# 2024, Vasyl Gello
#
-# The script is invoked by F-Droid builder system step-by-step.
+# The script is invoked by F-Droid builder system ste-by-step.
#
# It accepts the following arguments:
#
@@ -16,6 +16,7 @@
# - 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
#
@@ -183,9 +184,13 @@ prebuild)
fi
# Map NDK version to revision
- NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
- jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
- sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
+
+ 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
@@ -311,18 +316,6 @@ prebuild)
# `FLUTTER_BRIDGE_VERSION` an restore the pubspec later
if [ "${FLUTTER_VERSION}" != "${FLUTTER_BRIDGE_VERSION}" ]; then
- # Find first libclang.so and set BRIDGE_LLVM_PATH
-
- BRIDGE_LLVM_PATH="$(find /usr/lib/ -name libclang.so | head -n1)"
-
- if [ -z "${BRIDGE_LLVM_PATH}" ]; then
- echo 'ERROR: Can not find libclang.so for bridge generator!' >&2
- exit 1
- fi
-
- BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
- BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
-
# Install Flutter bridge version
prepare_flutter "${FLUTTER_BRIDGE_VERSION}" "${HOME}/flutter"
@@ -351,8 +344,7 @@ prebuild)
flutter_rust_bridge_codegen \
--rust-input ./src/flutter_ffi.rs \
- --dart-output ./flutter/lib/generated_bridge.dart \
- --llvm-path "${BRIDGE_LLVM_PATH}"
+ --dart-output ./flutter/lib/generated_bridge.dart
# Add bridge files to save-list
@@ -363,15 +355,13 @@ prebuild)
git checkout '*'
git clean -dffx
git reset
-
- unset BRIDGE_LLVM_PATH
fi
# Install Flutter version for RustDesk library build
prepare_flutter "${FLUTTER_VERSION}" "${HOME}/flutter"
- # gms is not in these files now, but we still keep the following line for future reference(maybe).
+ # gms is not in thoes files now, but we still keep the following line for future reference(maybe).
sed \
-i \
@@ -424,9 +414,13 @@ build)
.github/workflows/flutter-build.yml)"
# Map NDK version to revision
- NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
- jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
- sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
+
+ 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
diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart
index 366a7b6ba..07340e16b 100644
--- a/flutter/lib/common.dart
+++ b/flutter/lib/common.dart
@@ -24,7 +24,6 @@ import 'package:provider/provider.dart';
import 'package:uni_links/uni_links.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart';
-import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import 'package:window_size/window_size.dart' as window_size;
@@ -716,17 +715,6 @@ closeConnection({String? id}) {
stateGlobal.isInMainPage = true;
} else {
final controller = Get.find();
- if (controller.tabType == DesktopTabType.terminal &&
- controller.onCloseWindow != null) {
- // Terminal windows are scoped to one peer. The optional id passed to
- // closeConnection() is that peer id, not a terminal tab key
- // (${peerId}_${terminalId}). Closing from terminal dialogs should close
- // the peer's whole terminal window, including all terminal tabs.
- unawaited(controller.onCloseWindow!().catchError((e, _) {
- debugPrint('[closeConnection] Failed to close terminal window: $e');
- }));
- return;
- }
controller.closeBy(id);
}
}
@@ -1022,15 +1010,13 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
});
}
-void showToast(String text,
- {Duration timeout = const Duration(seconds: 3),
- Alignment alignment = const Alignment(0.0, 0.8)}) {
+void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) {
final overlayState = globalKey.currentState?.overlay;
if (overlayState == null) return;
final entry = OverlayEntry(builder: (context) {
return IgnorePointer(
child: Align(
- alignment: alignment,
+ alignment: const Alignment(0.0, 0.8),
child: Container(
decoration: BoxDecoration(
color: MyTheme.color(context).toastBg,
@@ -1135,23 +1121,18 @@ class CustomAlertDialog extends StatelessWidget {
Widget createDialogContent(String text) {
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
- bool hasLink = linkRegExp.hasMatch(text);
-
- // Early return: no link, use default theme color
- if (!hasLink) {
- return SelectableText(text, style: const TextStyle(fontSize: 15));
- }
-
final List 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: const TextStyle(
+ style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
@@ -1169,9 +1150,13 @@ Widget createDialogContent(String text) {
spans.add(TextSpan(text: text.substring(start)));
}
+ if (!hasLink) {
+ return SelectableText(text, style: const TextStyle(fontSize: 15));
+ }
+
return SelectableText.rich(
TextSpan(
- style: const TextStyle(fontSize: 15),
+ style: TextStyle(color: Colors.black, fontSize: 15),
children: spans,
),
);
@@ -1590,7 +1575,7 @@ bool option2bool(String option, String value) {
option == kOptionForceAlwaysRelay) {
res = value == "Y";
} else {
- // "" is true
+ assert(false);
res = value != "N";
}
return res;
@@ -1608,6 +1593,9 @@ String bool2option(String option, bool b) {
option == kOptionForceAlwaysRelay) {
res = b ? 'Y' : defaultOptionNo;
} else {
+ if (option != kOptionEnableUdpPunch && option != kOptionEnableIpv6Punch) {
+ assert(false);
+ }
res = b ? 'Y' : 'N';
}
return res;
@@ -1944,41 +1932,44 @@ Future _adjustRestoreMainWindowOffset(
return null;
}
+ double? frameLeft;
+ double? frameTop;
+ double? frameRight;
+ double? frameBottom;
+
if (isDesktop || isWebDesktop) {
- final screens = await window_size.getScreenList();
- if (screens.isNotEmpty) {
- final windowRect = Rect.fromLTWH(left, top, width, height);
- bool isVisible = false;
- for (final screen in screens) {
- final intersection = windowRect.intersect(screen.visibleFrame);
- if (intersection.width >= 10.0 && intersection.height >= 10.0) {
- isVisible = true;
- break;
- }
- }
- if (!isVisible) {
- return null;
- }
- return Offset(left, top);
+ for (final screen in await window_size.getScreenList()) {
+ frameLeft = frameLeft == null
+ ? screen.visibleFrame.left
+ : min(screen.visibleFrame.left, frameLeft);
+ frameTop = frameTop == null
+ ? screen.visibleFrame.top
+ : min(screen.visibleFrame.top, frameTop);
+ frameRight = frameRight == null
+ ? screen.visibleFrame.right
+ : max(screen.visibleFrame.right, frameRight);
+ frameBottom = frameBottom == null
+ ? screen.visibleFrame.bottom
+ : max(screen.visibleFrame.bottom, frameBottom);
}
}
-
- double frameLeft = 0.0;
- double frameTop = 0.0;
- double frameRight = ((isDesktop || isWebDesktop)
- ? kDesktopMaxDisplaySize
- : kMobileMaxDisplaySize)
- .toDouble();
- double frameBottom = ((isDesktop || isWebDesktop)
- ? kDesktopMaxDisplaySize
- : kMobileMaxDisplaySize)
- .toDouble();
-
+ if (frameLeft == null) {
+ frameLeft = 0.0;
+ frameTop = 0.0;
+ frameRight = ((isDesktop || isWebDesktop)
+ ? kDesktopMaxDisplaySize
+ : kMobileMaxDisplaySize)
+ .toDouble();
+ frameBottom = ((isDesktop || isWebDesktop)
+ ? kDesktopMaxDisplaySize
+ : kMobileMaxDisplaySize)
+ .toDouble();
+ }
final minWidth = 10.0;
- if ((left + minWidth) > frameRight ||
- (top + minWidth) > frameBottom ||
+ if ((left + minWidth) > frameRight! ||
+ (top + minWidth) > frameBottom! ||
(left + width - minWidth) < frameLeft ||
- top < frameTop) {
+ top < frameTop!) {
return null;
} else {
return Offset(left, top);
@@ -2376,19 +2367,6 @@ List? urlLinkToCmdArgs(Uri uri) {
id = uri.path.substring("/new/".length);
} else if (uri.authority == "config") {
if (isAndroid || isIOS) {
- final allowDeepLinkServerSettings =
- bind.mainGetBuildinOption(key: kOptionAllowDeepLinkServerSettings) ==
- 'Y';
- if (!allowDeepLinkServerSettings) {
- debugPrint(
- "Ignore rustdesk://config because $kOptionAllowDeepLinkServerSettings is not enabled.");
- // Keep the user-facing error generic; detailed rejection reason is in debug logs.
- // Delay toast to avoid missing overlay during cold-start deeplink handling.
- Timer(Duration(seconds: 1), () {
- showToast(translate('Failed'));
- });
- return null;
- }
final config = uri.path.substring("/".length);
// add a timer to make showToast work
Timer(Duration(seconds: 1), () {
@@ -2398,24 +2376,11 @@ List? urlLinkToCmdArgs(Uri uri) {
return null;
} else if (uri.authority == "password") {
if (isAndroid || isIOS) {
- final allowDeepLinkPassword =
- bind.mainGetBuildinOption(key: kOptionAllowDeepLinkPassword) == 'Y';
- if (!allowDeepLinkPassword) {
- debugPrint(
- "Ignore rustdesk://password because $kOptionAllowDeepLinkPassword is not enabled.");
- // Keep the user-facing error generic; detailed rejection reason is in debug logs.
- // Delay toast to avoid missing overlay during cold-start deeplink handling.
- Timer(Duration(seconds: 1), () {
- showToast(translate('Failed'));
- });
- return null;
- }
final password = uri.path.substring("/".length);
if (password.isNotEmpty) {
Timer(Duration(seconds: 1), () async {
- final ok =
- await bind.mainSetPermanentPasswordWithResult(password: password);
- showToast(translate(ok ? 'Successful' : 'Failed'));
+ await bind.mainSetPermanentPassword(password: password);
+ showToast(translate('Successful'));
});
}
}
@@ -2711,55 +2676,6 @@ class SimpleWrapper {
SimpleWrapper(this.value);
}
-/// Wakelock manager with reference counting for desktop.
-/// Ensures wakelock is only disabled when all sessions are closed/minimized.
-///
-/// Note: Each isolate has its own WakelockPlus instance with independent assertion.
-/// As long as one isolate has wakelock enabled, the screen stays awake.
-/// This manager handles multiple tabs within the same isolate.
-class WakelockManager {
- static final Set _enabledKeys = {};
- // Don't use WakelockPlus.enabled, it causes error on Android:
- // Unhandled Exception: FormatException: Message corrupted
- //
- // On Linux, multiple enable() calls create only one inhibit, but each disable()
- // only releases if _cookie != null. So we need our own _enabled state to avoid
- // calling disable() when not enabled.
- // See: https://github.com/fluttercommunity/wakelock_plus/blob/0c74e5bbc6aefac57b6c96bb7ef987705ed559ec/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart#L48
- static bool _enabled = false;
-
- static void enable(UniqueKey key, {bool isServer = false}) {
- // Check if we should keep awake during outgoing sessions
- if (!isServer) {
- final keepAwake =
- mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
- if (!keepAwake) {
- return; // Don't enable wakelock if user disabled keep awake
- }
- }
- if (isDesktop) {
- _enabledKeys.add(key);
- }
- if (!_enabled) {
- _enabled = true;
- WakelockPlus.enable();
- }
- }
-
- static void disable(UniqueKey key) {
- if (isDesktop) {
- _enabledKeys.remove(key);
- if (_enabledKeys.isNotEmpty) {
- return;
- }
- }
- if (_enabled) {
- WakelockPlus.disable();
- _enabled = false;
- }
- }
-}
-
/// call this to reload current window.
///
/// [Note]
@@ -3100,26 +3016,10 @@ Future start_service(bool is_start) async {
}
Future canBeBlocked() async {
- if (isWeb) {
- // Web can only act as a controller, never as a controlled side,
- // so it should never be blocked by a remote session.
- return false;
- }
- // First check control permission
- final controlPermission = await bind.mainGetCommon(
- key: "is-remote-modify-enabled-by-control-permissions");
- if (controlPermission == "true") {
- return false;
- } else if (controlPermission == "false") {
- return true;
- }
-
- // Check local settings
- var accessMode = await bind.mainGetOption(key: kOptionAccessMode);
- var isCustomAccessMode = accessMode != 'full' && accessMode != 'view';
+ var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
var option = option2bool(kOptionAllowRemoteConfigModification,
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
- return accessMode == 'view' || (isCustomAccessMode && !option);
+ return access_mode == 'view' || (access_mode.isEmpty && !option);
}
// to-do: web not implemented
@@ -3882,16 +3782,6 @@ setResizable(bool resizable) {
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
-bool isChangePermanentPasswordDisabled() =>
- bind.mainGetBuildinOption(key: kOptionDisableChangePermanentPassword) ==
- 'Y';
-
-bool isChangeIdDisabled() =>
- bind.mainGetBuildinOption(key: kOptionDisableChangeId) == 'Y';
-
-bool isUnlockPinDisabled() =>
- bind.mainGetBuildinOption(key: kOptionDisableUnlockPin) == 'Y';
-
bool? _isCustomClient;
bool get isCustomClient {
_isCustomClient ??= bind.isCustomClient();
@@ -4135,62 +4025,3 @@ String decode_http_response(http.Response resp) {
bool peerTabShowNote(PeerTabIndex peerTabIndex) {
return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group;
}
-
-// TODO: We should support individual bits combinations in the future.
-// But for now, just keep it simple, because the old code only supports single button.
-// No users have requested multi-button support yet.
-String mouseButtonsToPeer(int buttons) {
- switch (buttons) {
- case kPrimaryMouseButton:
- return 'left';
- case kSecondaryMouseButton:
- return 'right';
- case kMiddleMouseButton:
- return 'wheel';
- case kBackMouseButton:
- return 'back';
- case kForwardMouseButton:
- return 'forward';
- default:
- return '';
- }
-}
-
-/// Build an avatar widget from an avatar URL or data URI string.
-/// Returns [fallback] if avatar is empty or cannot be decoded.
-/// [borderRadius] defaults to [size]/2 (circle).
-Widget? buildAvatarWidget({
- required String avatar,
- required double size,
- double? borderRadius,
- Widget? fallback,
-}) {
- final trimmed = avatar.trim();
- if (trimmed.isEmpty) return fallback;
-
- ImageProvider? imageProvider;
- if (trimmed.startsWith('data:image/')) {
- final comma = trimmed.indexOf(',');
- if (comma > 0) {
- try {
- imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1)));
- } catch (_) {}
- }
- } else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
- imageProvider = NetworkImage(trimmed);
- }
-
- if (imageProvider == null) return fallback;
-
- final radius = borderRadius ?? size / 2;
- return ClipRRect(
- borderRadius: BorderRadius.circular(radius),
- child: Image(
- image: imageProvider,
- width: size,
- height: size,
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
- ),
- );
-}
diff --git a/flutter/lib/common/hbbs/hbbs.dart b/flutter/lib/common/hbbs/hbbs.dart
index 0c729e4df..aab8ba597 100644
--- a/flutter/lib/common/hbbs/hbbs.dart
+++ b/flutter/lib/common/hbbs/hbbs.dart
@@ -25,8 +25,6 @@ enum UserStatus { kDisabled, kNormal, kUnverified }
// Is all the fields of the user needed?
class UserPayload {
String name = '';
- String displayName = '';
- String avatar = '';
String email = '';
String note = '';
String? verifier;
@@ -35,8 +33,6 @@ class UserPayload {
UserPayload.fromJson(Map json)
: name = json['name'] ?? '',
- displayName = json['display_name'] ?? '',
- avatar = json['avatar'] ?? '',
email = json['email'] ?? '',
note = json['note'] ?? '',
verifier = json['verifier'],
@@ -50,8 +46,6 @@ class UserPayload {
Map toJson() {
final Map map = {
'name': name,
- 'display_name': displayName,
- 'avatar': avatar,
'status': status == UserStatus.kDisabled
? 0
: status == UserStatus.kUnverified
@@ -64,14 +58,9 @@ class UserPayload {
Map toGroupCacheJson() {
final Map map = {
'name': name,
- 'display_name': displayName,
};
return map;
}
-
- String get displayNameOrName {
- return displayName.trim().isEmpty ? name : displayName;
- }
}
class PeerPayload {
diff --git a/flutter/lib/common/widgets/address_book.dart b/flutter/lib/common/widgets/address_book.dart
index 054a1666c..1a09d6f53 100644
--- a/flutter/lib/common/widgets/address_book.dart
+++ b/flutter/lib/common/widgets/address_book.dart
@@ -54,9 +54,9 @@ class _AddressBookState extends State {
const LinearProgressIndicator(),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
- err: gFFI.abModel.abPullError,
+ err: gFFI.abModel.currentAbPullError,
retry: null,
- close: gFFI.abModel.clearPullErrors),
+ close: () => gFFI.abModel.currentAbPullError.value = ''),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
err: gFFI.abModel.currentAbPushError,
diff --git a/flutter/lib/common/widgets/gestures.dart b/flutter/lib/common/widgets/gestures.dart
index 0501ca453..74b1642b7 100644
--- a/flutter/lib/common/widgets/gestures.dart
+++ b/flutter/lib/common/widgets/gestures.dart
@@ -25,7 +25,6 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
GestureDragStartCallback? onOneFingerPanStart;
GestureDragUpdateCallback? onOneFingerPanUpdate;
GestureDragEndCallback? onOneFingerPanEnd;
- GestureDragCancelCallback? onOneFingerPanCancel;
// twoFingerScale : scale + pan event
GestureScaleStartCallback? onTwoFingerScaleStart;
@@ -170,27 +169,6 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
DragEndDetails(velocity: d.velocity);
-
- @override
- void rejectGesture(int pointer) {
- super.rejectGesture(pointer);
- switch (_currentState) {
- case GestureState.oneFingerPan:
- if (onOneFingerPanCancel != null) {
- onOneFingerPanCancel!();
- }
- break;
- case GestureState.twoFingerScale:
- // Reset scale state if needed, currently self-contained
- break;
- case GestureState.threeFingerVerticalDrag:
- // Reset drag state if needed, currently self-contained
- break;
- default:
- break;
- }
- _currentState = GestureState.none;
- }
}
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
@@ -739,7 +717,6 @@ RawGestureDetector getMixinGestureDetector({
GestureDragStartCallback? onOneFingerPanStart,
GestureDragUpdateCallback? onOneFingerPanUpdate,
GestureDragEndCallback? onOneFingerPanEnd,
- GestureDragCancelCallback? onOneFingerPanCancel,
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
GestureScaleEndCallback? onTwoFingerScaleEnd,
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
@@ -788,7 +765,6 @@ RawGestureDetector getMixinGestureDetector({
..onOneFingerPanStart = onOneFingerPanStart
..onOneFingerPanUpdate = onOneFingerPanUpdate
..onOneFingerPanEnd = onOneFingerPanEnd
- ..onOneFingerPanCancel = onOneFingerPanCancel
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
diff --git a/flutter/lib/common/widgets/login.dart b/flutter/lib/common/widgets/login.dart
index ee376de68..5fafc87b9 100644
--- a/flutter/lib/common/widgets/login.dart
+++ b/flutter/lib/common/widgets/login.dart
@@ -20,8 +20,7 @@ const kOpSvgList = [
'okta',
'facebook',
'azure',
- 'auth0',
- 'microsoft'
+ 'auth0'
];
class _IconOP extends StatelessWidget {
@@ -104,7 +103,7 @@ class ButtonOP extends StatelessWidget {
child: FittedBox(
fit: BoxFit.scaleDown,
child: Center(
- child: Text(translate("Continue with {$opLabel}"))),
+ child: Text('${translate("Continue with")} $opLabel')),
),
),
],
@@ -225,59 +224,21 @@ class _WidgetOPState extends State {
return Offstage(
offstage:
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
- Padding(
- padding: const EdgeInsets.only(top: 8.0),
- child: SelectableText(
- translate(_stateMsg),
- style: DefaultTextStyle.of(context)
- .style
- .copyWith(fontSize: 12),
- ),
- ),
- if (_failedMsg.isNotEmpty)
- Padding(
- padding: const EdgeInsets.only(top: 8.0),
- child: Builder(builder: (context) {
- final errorColor =
- Theme.of(context).colorScheme.error;
- final bgColor = Theme.of(context)
- .colorScheme
- .errorContainer
- .withOpacity(0.3);
- return Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8.0, vertical: 6.0),
- decoration: BoxDecoration(
- color: bgColor,
- borderRadius: BorderRadius.circular(4.0),
+ child: RichText(
+ text: TextSpan(
+ text: '$_stateMsg ',
+ style:
+ DefaultTextStyle.of(context).style.copyWith(fontSize: 12),
+ children: [
+ TextSpan(
+ text: _failedMsg,
+ style: DefaultTextStyle.of(context).style.copyWith(
+ fontSize: 14,
+ color: Colors.red,
),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.error_outline,
- color: errorColor, size: 16),
- const SizedBox(width: 6),
- Flexible(
- child: SelectableText(
- translate(_failedMsg),
- style: DefaultTextStyle.of(context)
- .style
- .copyWith(
- fontSize: 13,
- color: errorColor,
- ),
- ),
- ),
- ],
- ),
- );
- }),
),
- ],
+ ],
+ ),
),
);
}),
diff --git a/flutter/lib/common/widgets/my_group.dart b/flutter/lib/common/widgets/my_group.dart
index 74ce34e71..6207a7363 100644
--- a/flutter/lib/common/widgets/my_group.dart
+++ b/flutter/lib/common/widgets/my_group.dart
@@ -158,18 +158,12 @@ class _MyGroupState extends State {
return Obx(() {
final userItems = gFFI.groupModel.users.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
- final search = searchAccessibleItemNameText.value.toLowerCase();
- return p0.name.toLowerCase().contains(search) ||
- p0.displayNameOrName.toLowerCase().contains(search);
+ return p0.name
+ .toLowerCase()
+ .contains(searchAccessibleItemNameText.value.toLowerCase());
}
return true;
}).toList();
- // Count occurrences of each displayNameOrName to detect duplicates
- final displayNameCount = {};
- for (final u in userItems) {
- final dn = u.displayNameOrName;
- displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1;
- }
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name
@@ -183,8 +177,7 @@ class _MyGroupState extends State {
itemCount: deviceGroupItems.length + userItems.length,
itemBuilder: (context, index) => index < deviceGroupItems.length
? _buildDeviceGroupItem(deviceGroupItems[index])
- : _buildUserItem(userItems[index - deviceGroupItems.length],
- displayNameCount));
+ : _buildUserItem(userItems[index - deviceGroupItems.length]));
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return Obx(() => stateGlobal.isPortrait.isFalse
? listView(false)
@@ -192,14 +185,8 @@ class _MyGroupState extends State {
});
}
- Widget _buildUserItem(UserPayload user, Map displayNameCount) {
+ Widget _buildUserItem(UserPayload user) {
final username = user.name;
- final dn = user.displayNameOrName;
- final isDuplicate = (displayNameCount[dn] ?? 0) > 1;
- final displayName =
- isDuplicate && user.displayName.trim().isNotEmpty
- ? '${user.displayName} (@$username)'
- : dn;
return InkWell(onTap: () {
isSelectedDeviceGroup.value = false;
if (selectedAccessibleItemName.value != username) {
@@ -235,14 +222,14 @@ class _MyGroupState extends State {
alignment: Alignment.center,
child: Center(
child: Text(
- displayName.characters.first.toUpperCase(),
+ username.characters.first.toUpperCase(),
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
).marginOnly(right: 4),
- if (isMe) Flexible(child: Text(displayName)),
+ if (isMe) Flexible(child: Text(username)),
if (isMe)
Flexible(
child: Container(
@@ -259,7 +246,7 @@ class _MyGroupState extends State {
),
),
),
- if (!isMe) Expanded(child: Text(displayName)),
+ if (!isMe) Expanded(child: Text(username)),
],
).paddingSymmetric(vertical: 4),
),
diff --git a/flutter/lib/common/widgets/peers_view.dart b/flutter/lib/common/widgets/peers_view.dart
index 5be5af272..d81a095ca 100644
--- a/flutter/lib/common/widgets/peers_view.dart
+++ b/flutter/lib/common/widgets/peers_view.dart
@@ -570,14 +570,11 @@ class MyGroupPeerView extends BasePeersView {
static bool filter(Peer peer) {
final model = gFFI.groupModel;
if (model.searchAccessibleItemNameText.isNotEmpty) {
- final text = model.searchAccessibleItemNameText.value.toLowerCase();
- final searchPeersOfUser = model.users.any((user) =>
- user.name == peer.loginName &&
- (user.name.toLowerCase().contains(text) ||
- user.displayNameOrName.toLowerCase().contains(text)));
- final searchPeersOfDeviceGroup =
- peer.device_group_name.toLowerCase().contains(text) &&
- model.deviceGroups.any((g) => g.name == peer.device_group_name);
+ final text = model.searchAccessibleItemNameText.value;
+ final searchPeersOfUser = peer.loginName.contains(text) &&
+ model.users.any((user) => user.name == peer.loginName);
+ final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
+ model.deviceGroups.any((g) => g.name == peer.device_group_name);
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
return false;
}
diff --git a/flutter/lib/common/widgets/remote_input.dart b/flutter/lib/common/widgets/remote_input.dart
index 9515ca759..f75e0027b 100644
--- a/flutter/lib/common/widgets/remote_input.dart
+++ b/flutter/lib/common/widgets/remote_input.dart
@@ -31,7 +31,7 @@ class RawKeyFocusScope extends StatelessWidget {
// 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 separated key events for en-US input method.
+ // while `Alt` and `Control` are seperated key events for en-US input method.
return FocusScope(
autofocus: true,
child: Focus(
@@ -107,8 +107,6 @@ class _RawTouchGestureDetectorRegionState
// For mouse mode, we need to block the events when the cursor is in a blocked area.
// So we need to cache the last tap down position.
Offset? _lastTapDownPositionForMouseMode;
- // Cache global position for onTap (which lacks position info).
- Offset? _lastTapDownGlobalPosition;
FFI get ffi => widget.ffi;
FfiModel get ffiModel => widget.ffiModel;
@@ -138,7 +136,6 @@ class _RawTouchGestureDetectorRegionState
onTapDown(TapDownDetails d) async {
lastDeviceKind = d.kind;
- _lastTapDownGlobalPosition = d.globalPosition;
if (isNotTouchBasedDevice()) {
return;
}
@@ -157,16 +154,11 @@ class _RawTouchGestureDetectorRegionState
if (isNotTouchBasedDevice()) {
return;
}
- // Filter duplicate touch tap events on iOS (Magic Mouse issue).
- if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
- return;
- }
if (handleTouch) {
final isMoved =
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
if (isMoved) {
- // If pan already handled 'down', don't send it again.
- if (lastTapDownDetails != null && !_touchModePanStarted) {
+ if (lastTapDownDetails != null) {
await inputModel.tapDown(MouseButtons.left);
}
await inputModel.tapUp(MouseButtons.left);
@@ -178,11 +170,6 @@ class _RawTouchGestureDetectorRegionState
if (isNotTouchBasedDevice()) {
return;
}
- // Filter duplicate touch tap events on iOS (Magic Mouse issue).
- final lastPos = _lastTapDownGlobalPosition;
- if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
- return;
- }
if (!handleTouch) {
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
// Using `_lastTapDownPositionForMouseMode` instead.
@@ -385,10 +372,7 @@ class _RawTouchGestureDetectorRegionState
await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
}
- // In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
- if (!inputModel.relativeMouseMode.value) {
- await inputModel.sendMouse('down', MouseButtons.left);
- }
+ await inputModel.sendMouse('down', MouseButtons.left);
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
} else {
final offset = ffi.cursorModel.offset;
@@ -413,12 +397,7 @@ class _RawTouchGestureDetectorRegionState
if (handleTouch && !_touchModePanStarted) {
return;
}
- // In relative mouse mode, send delta directly without position tracking.
- if (inputModel.relativeMouseMode.value) {
- await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
- } else {
- await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
- }
+ await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
}
onOneFingerPanEnd(DragEndDetails d) async {
@@ -430,21 +409,10 @@ class _RawTouchGestureDetectorRegionState
ffi.cursorModel.clearRemoteWindowCoords();
}
if (handleTouch) {
- // In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
- if (!inputModel.relativeMouseMode.value) {
- await inputModel.sendMouse('up', MouseButtons.left);
- }
+ await inputModel.sendMouse('up', MouseButtons.left);
}
}
- // Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled
- // or rejected by the gesture arena. Without this, the flag can remain
- // stuck in the "started" state and cause issues such as the Magic Mouse
- // double-click problem on iPad with magic mouse.
- onOneFingerPanCancel() {
- _touchModePanStarted = false;
- }
-
// scale + pan event
onTwoFingerScaleStart(ScaleStartDetails d) {
_lastTapDownDetails = null;
@@ -532,9 +500,7 @@ class _RawTouchGestureDetectorRegionState
// Official
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers(
- () => TapGestureRecognizer(
- supportedDevices: kTouchBasedDeviceKinds,
- ), (instance) {
+ () => TapGestureRecognizer(), (instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
@@ -542,18 +508,14 @@ class _RawTouchGestureDetectorRegionState
}),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers(
- () => DoubleTapGestureRecognizer(
- supportedDevices: kTouchBasedDeviceKinds,
- ), (instance) {
+ () => DoubleTapGestureRecognizer(), (instance) {
instance
..onDoubleTapDown = onDoubleTapDown
..onDoubleTap = onDoubleTap;
}),
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers(
- () => LongPressGestureRecognizer(
- supportedDevices: kTouchBasedDeviceKinds,
- ), (instance) {
+ () => LongPressGestureRecognizer(), (instance) {
instance
..onLongPressDown = onLongPressDown
..onLongPressUp = onLongPressUp
@@ -563,9 +525,7 @@ class _RawTouchGestureDetectorRegionState
// Customized
HoldTapMoveGestureRecognizer:
GestureRecognizerFactoryWithHandlers(
- () => HoldTapMoveGestureRecognizer(
- supportedDevices: kTouchBasedDeviceKinds,
- ),
+ () => HoldTapMoveGestureRecognizer(),
(instance) => instance
..onHoldDragStart = onHoldDragStart
..onHoldDragUpdate = onHoldDragUpdate
@@ -573,24 +533,19 @@ class _RawTouchGestureDetectorRegionState
..onHoldDragEnd = onHoldDragEnd),
DoubleFinerTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers(
- () => DoubleFinerTapGestureRecognizer(
- supportedDevices: kTouchBasedDeviceKinds,
- ), (instance) {
+ () => DoubleFinerTapGestureRecognizer(), (instance) {
instance
..onDoubleFinerTap = onDoubleFinerTap
..onDoubleFinerTapDown = onDoubleFinerTapDown;
}),
CustomTouchGestureRecognizer:
GestureRecognizerFactoryWithHandlers(
- () => CustomTouchGestureRecognizer(
- supportedDevices: kTouchBasedDeviceKinds,
- ), (instance) {
+ () => CustomTouchGestureRecognizer(), (instance) {
instance.onOneFingerPanStart =
(DragStartDetails d) => onOneFingerPanStart(context, d);
instance
..onOneFingerPanUpdate = onOneFingerPanUpdate
..onOneFingerPanEnd = onOneFingerPanEnd
- ..onOneFingerPanCancel = onOneFingerPanCancel
..onTwoFingerScaleStart = onTwoFingerScaleStart
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart
index 537014246..b158679eb 100644
--- a/flutter/lib/common/widgets/toolbar.dart
+++ b/flutter/lib/common/widgets/toolbar.dart
@@ -6,22 +6,14 @@ import 'package:flutter/services.dart';
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/common/widgets/login.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:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
bool isEditOsPassword = false;
-// macOS privacy mode blacks out all online displays, so switching the remote
-// display does not weaken the local privacy protection.
-bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) {
- return pi.platform == kPeerPlatformMacOS;
-}
-
class TTextMenu {
final Widget child;
final VoidCallback? onPressed;
@@ -201,26 +193,14 @@ List toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// note
- if (isDefaultConn && !bind.isDisableAccount()) {
+ if (isDefaultConn &&
+ bind
+ .sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
+ .isNotEmpty) {
v.add(
TTextMenu(
child: Text(translate('Note')),
- onPressed: () async {
- bool isLogin =
- bind.mainGetLocalOption(key: 'access_token').isNotEmpty;
- if (!isLogin) {
- final res = await loginDialog();
- if (res != true) return;
- // Desktop: send message to main window to refresh login status
- // Web: login is required before connection, so no need to refresh
- // Mobile: same isolate, no need to send message
- if (isDesktop) {
- rustDeskWinManager.call(
- WindowType.Main, kWindowRefreshCurrentUser, "");
- }
- }
- showAuditDialog(ffi);
- }),
+ onPressed: () => showAuditDialog(ffi)),
);
}
// divider
@@ -281,6 +261,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) {
isDesktop &&
ffiModel.keyboard &&
pi.platform != kPeerPlatformAndroid &&
+ pi.platform != kPeerPlatformMacOS &&
versionCmp(pi.version, '1.2.0') >= 0 &&
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
v.add(TTextMenu(
@@ -690,9 +671,8 @@ Future> toolbarDisplayToggle(
child: Text(translate('Lock after session end'))));
}
- final privacyModeState = PrivacyModeState.find(id);
if (pi.isSupportMultiDisplay &&
- (privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
+ PrivacyModeState.find(id).isEmpty &&
pi.displaysCount.value > 1 &&
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
final value =
@@ -766,25 +746,15 @@ List toolbarPrivacyMode(
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final sessionId = ffi.sessionId;
- final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false;
-
- // Backend revocation already attempts to turn privacy mode off.
- // Still keep this menu when privacy mode is active, so users can turn it off
- // if there is a sync delay, version mismatch, or off attempt failure.
- if (!hasPrivacyModePermission && privacyModeState.isEmpty) {
- return []; // No permission and not active, hide options.
- }
getDefaultMenu(Future Function(SessionID sid, String opt) toggleFunc) {
- final enabled =
- !ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty);
+ final enabled = !ffi.ffiModel.viewOnly;
return TToggleMenu(
value: privacyModeState.isNotEmpty,
onChanged: enabled
? (value) {
if (value == null) return;
- if (!allowDisplaySwitchInPrivacyMode(pi) &&
- ffiModel.pi.currentDisplay != 0 &&
+ if (ffiModel.pi.currentDisplay != 0 &&
ffiModel.pi.currentDisplay != kAllDisplayValue) {
msgBox(
sessionId,
@@ -827,29 +797,18 @@ List toolbarPrivacyMode(
})
];
} else {
- final visibleImpls = hasPrivacyModePermission
- ? privacyModeImpls
- : privacyModeImpls.where((e) {
- final implKey = (e as List)[0] as String;
- return privacyModeState.value == implKey;
- }).toList();
- return visibleImpls.map((e) {
+ return privacyModeImpls.map((e) {
final implKey = (e as List)[0] as String;
final implName = (e)[1] as String;
- final enabled = !ffiModel.viewOnly &&
- (hasPrivacyModePermission || privacyModeState.value == implKey);
return TToggleMenu(
child: Text(translate(implName)),
value: privacyModeState.value == implKey,
- onChanged: enabled
- ? (value) {
- if (value == null) return;
- if (value && !hasPrivacyModePermission) return;
- togglePrivacyModeTime = DateTime.now();
- bind.sessionTogglePrivacyMode(
- sessionId: sessionId, implKey: implKey, on: value);
- }
- : null);
+ onChanged: (value) {
+ if (value == null) return;
+ togglePrivacyModeTime = DateTime.now();
+ bind.sessionTogglePrivacyMode(
+ sessionId: sessionId, implKey: implKey, on: value);
+ });
}).toList();
}
}
@@ -858,7 +817,6 @@ List toolbarKeyboardToggles(FFI ffi) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final sessionId = ffi.sessionId;
- final isDefaultConn = ffi.connType == ConnType.defaultConn;
List v = [];
// swap key
@@ -880,34 +838,6 @@ List toolbarKeyboardToggles(FFI ffi) {
child: Text(translate('Swap control-command key'))));
}
- // Relative mouse mode (gaming mode).
- // Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5)
- // Note: This feature is only available in Flutter client. Sciter client does not support this.
- // Web client is not supported yet due to Pointer Lock API integration complexity with Flutter's input system.
- // Wayland is not supported due to cursor warping limitations.
- // Mobile: This option is now in GestureHelp widget, shown only when joystick is visible.
- final isWayland = isDesktop && isLinux && bind.mainCurrentIsWayland();
- if (isDesktop &&
- isDefaultConn &&
- !isWeb &&
- !isWayland &&
- ffiModel.keyboard &&
- !ffiModel.viewOnly &&
- ffi.inputModel.isRelativeMouseModeSupported) {
- v.add(TToggleMenu(
- value: ffi.inputModel.relativeMouseMode.value,
- onChanged: (value) {
- if (value == null) return;
- final previousValue = ffi.inputModel.relativeMouseMode.value;
- final success = ffi.inputModel.setRelativeMouseMode(value);
- if (!success) {
- // Revert the observable toggle to reflect the actual state
- ffi.inputModel.relativeMouseMode.value = previousValue;
- }
- },
- child: Text(translate('Relative mouse mode'))));
- }
-
// reverse mouse wheel
if (ffiModel.keyboard) {
var optionValue =
diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart
index adf7b1d45..cf91e14d2 100644
--- a/flutter/lib/consts.dart
+++ b/flutter/lib/consts.dart
@@ -50,7 +50,6 @@ const String kAppTypeDesktopPortForward = "port forward";
const String kAppTypeDesktopTerminal = "terminal";
const String kWindowMainWindowOnTop = "main_window_on_top";
-const String kWindowRefreshCurrentUser = "refresh_current_user";
const String kWindowGetWindowInfo = "get_window_info";
const String kWindowGetScreenList = "get_screen_list";
// This method is not used, maybe it can be removed.
@@ -114,9 +113,6 @@ const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";
-const String kOptionEnablePrivacyMode = "enable-privacy-mode";
-const String kOptionEnablePermChangeInAcceptWindow =
- "enable-perm-change-in-accept-window";
const String kOptionAllowRemoteConfigModification =
"allow-remote-config-modification";
const String kOptionVerificationMethod = "verification-method";
@@ -124,7 +120,6 @@ const String kOptionApproveMode = "approve-mode";
const String kOptionAllowNumericOneTimePassword =
"allow-numeric-one-time-password";
const String kOptionCollapseToolbar = "collapse_toolbar";
-const String kOptionHideToolbar = "hide-toolbar";
const String kOptionShowRemoteCursor = "show_remote_cursor";
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
const String kOptionFollowRemoteWindow = "follow_remote_window";
@@ -142,10 +137,6 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
const String kOptionCodecPreference = "codec-preference";
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
-const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
-const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
-const String kOptionAllowMultiEdgeToolbarDock =
- "allow-multi-edge-toolbar-dock";
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
const String kOptionRemoteMenubarState = "remoteMenubarState";
const String kOptionPeerSorting = "peer-sorting";
@@ -170,7 +161,6 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
-const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
// network options
const String kOptionAllowWebSocket = "allow-websocket";
@@ -182,21 +172,13 @@ const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
const String kOptionHideServerSetting = "hide-server-settings";
const String kOptionHideProxySetting = "hide-proxy-settings";
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
-const String kOptionHideStopService = "hide-stop-service";
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
const String kOptionHideSecuritySetting = "hide-security-settings";
const String kOptionHideNetworkSetting = "hide-network-settings";
const String kOptionRemovePresetPasswordWarning =
"remove-preset-password-warning";
-const String kOptionDisableChangePermanentPassword =
- "disable-change-permanent-password";
-const String kOptionDisableChangeId = "disable-change-id";
-const String kOptionDisableUnlockPin = "disable-unlock-pin";
const kHideUsernameOnCard = "hide-username-on-card";
const String kOptionHideHelpCards = "hide-help-cards";
-const String kOptionAllowDeepLinkPassword = "allow-deep-link-password";
-const String kOptionAllowDeepLinkServerSettings =
- "allow-deep-link-server-settings";
const String kOptionToggleViewOnly = "view-only";
const String kOptionToggleShowMyCursor = "show-my-cursor";
@@ -205,9 +187,6 @@ const String kOptionDisableFloatingWindow = "disable-floating-window";
const String kOptionKeepScreenOn = "keep-screen-on";
-const String kOptionKeepAwakeDuringIncomingSessions = "keep-awake-during-incoming-sessions";
-const String kOptionKeepAwakeDuringOutgoingSessions = "keep-awake-during-outgoing-sessions";
-
const String kOptionShowMobileAction = "showMobileActions";
const String kUrlActionClose = "close";
@@ -272,33 +251,6 @@ const int kMinTrackpadSpeed = 10;
const int kDefaultTrackpadSpeed = 100;
const int kMaxTrackpadSpeed = 1000;
-// relative mouse mode
-/// Throttle duration (in milliseconds) for updating pointer lock center during
-/// window move/resize events. Lower values provide more responsive updates but
-/// may cause performance issues during rapid window operations.
-const int kDefaultPointerLockCenterThrottleMs = 100;
-
-/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE).
-/// Servers older than this version will ignore relative mouse events.
-///
-/// IMPORTANT: This value must be kept in sync with the Rust constant
-/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`.
-const String kMinVersionForRelativeMouseMode = '1.4.5';
-
-/// Maximum delta value for relative mouse movement.
-/// Large values could cause issues with i32 overflow on server side,
-/// and no reasonable mouse movement should exceed this bound.
-///
-/// IMPORTANT: This value must be kept in sync with the Rust constant
-/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`.
-const int kMaxRelativeMouseDelta = 10000;
-
-/// Debounce duration (in milliseconds) for relative mouse mode toggle.
-/// This prevents double-toggle from race condition between Rust rdev grab loop
-/// and Flutter keyboard handling. Value should be small enough to allow
-/// intentional quick toggles but large enough to prevent accidental double-triggers.
-const int kRelativeMouseModeToggleDebounceMs = 150;
-
// incomming (should be incoming) is kept, because change it will break the previous setting.
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
const String kValuePrinterIncomingJobDismiss = 'dismiss';
diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart
index 42ec10032..b8b7c0286 100644
--- a/flutter/lib/desktop/pages/desktop_home_page.dart
+++ b/flutter/lib/desktop/pages/desktop_home_page.dart
@@ -450,11 +450,7 @@ class _DesktopHomePageState extends State
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
btnText,
onPressed,
- closeButton: true,
- help: isToUpdate ? 'Changelog' : null,
- link: isToUpdate
- ? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}'
- : null);
+ closeButton: true);
}
if (systemError.isNotEmpty) {
return buildInstallCard("", systemError, "", () {});
@@ -780,8 +776,6 @@ class _DesktopHomePageState extends State
}
if (call.method == kWindowMainWindowOnTop) {
windowOnTop(null);
- } else if (call.method == kWindowRefreshCurrentUser) {
- gFFI.userModel.refreshCurrentUser();
} else if (call.method == kWindowGetWindowInfo) {
final screen = (await window_size.getWindowInfo()).screen;
if (screen == null) {
@@ -908,17 +902,12 @@ class _DesktopHomePageState extends State
}
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
- final p0 = TextEditingController(text: "");
- final p1 = TextEditingController(text: "");
+ final pw = await bind.mainGetPermanentPassword();
+ final p0 = TextEditingController(text: pw);
+ final p1 = TextEditingController(text: pw);
var errMsg0 = "";
var errMsg1 = "";
- final localPasswordSet =
- (await bind.mainGetCommon(key: "local-permanent-password-set")) == "true";
- final permanentPasswordSet =
- (await bind.mainGetCommon(key: "permanent-password-set")) == "true";
- final presetPassword = permanentPasswordSet && !localPasswordSet;
- var canSubmit = false;
- final RxString rxPass = "".obs;
+ final RxString rxPass = pw.trim().obs;
final rules = [
DigitValidationRule(),
UppercaseValidationRule(),
@@ -927,21 +916,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
MinCharactersValidationRule(8),
];
final maxLength = bind.mainMaxEncryptLen();
- final statusTip = localPasswordSet
- ? translate('password-hidden-tip')
- : (presetPassword ? translate('preset-password-in-use-tip') : '');
- final showStatusTipOnMobile =
- statusTip.isNotEmpty && !isDesktop && !isWebDesktop;
gFFI.dialogManager.show((setState, close, context) {
- updateCanSubmit() {
- canSubmit = p0.text.trim().isNotEmpty || p1.text.trim().isNotEmpty;
- }
-
- submit() async {
- if (!canSubmit) {
- return;
- }
+ submit() {
setState(() {
errMsg0 = "";
errMsg1 = "";
@@ -964,13 +941,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
});
return;
}
- final ok = await bind.mainSetPermanentPasswordWithResult(password: pass);
- if (!ok) {
- setState(() {
- errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
- });
- return;
- }
+ bind.mainSetPermanentPassword(password: pass);
if (pass.isNotEmpty) {
notEmptyCallback?.call();
}
@@ -978,20 +949,14 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
}
return CustomAlertDialog(
- title: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(Icons.key, color: MyTheme.accent),
- Text(translate("Set Password")).paddingOnly(left: 10),
- ],
- ),
+ title: Text(translate("Set Password")),
content: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 500),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- SizedBox(
- height: showStatusTipOnMobile ? 0.0 : 6.0,
+ const SizedBox(
+ height: 8.0,
),
Row(
children: [
@@ -1007,7 +972,6 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
rxPass.value = value.trim();
setState(() {
errMsg0 = '';
- updateCanSubmit();
});
},
maxLength: maxLength,
@@ -1019,9 +983,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
children: [
Expanded(child: PasswordStrengthIndicator(password: rxPass)),
],
- ).marginOnly(top: 2, bottom: showStatusTipOnMobile ? 2 : 8),
- SizedBox(
- height: showStatusTipOnMobile ? 0.0 : 8.0,
+ ).marginSymmetric(vertical: 8),
+ const SizedBox(
+ height: 8.0,
),
Row(
children: [
@@ -1035,7 +999,6 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
onChanged: (value) {
setState(() {
errMsg1 = '';
- updateCanSubmit();
});
},
maxLength: maxLength,
@@ -1043,23 +1006,11 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
),
],
),
- if (statusTip.isNotEmpty)
- Row(
- children: [
- Icon(Icons.info, color: Colors.amber, size: 18)
- .marginOnly(right: 6),
- Expanded(
- child: Text(
- statusTip,
- style: const TextStyle(fontSize: 13, height: 1.1),
- ))
- ],
- ).marginOnly(top: 6, bottom: 2),
- SizedBox(
- height: showStatusTipOnMobile ? 0.0 : 8.0,
+ const SizedBox(
+ height: 8.0,
),
Obx(() => Wrap(
- runSpacing: showStatusTipOnMobile ? 2.0 : 8.0,
+ runSpacing: 8,
spacing: 4,
children: rules.map((e) {
var checked = e.validate(rxPass.value.trim());
@@ -1079,67 +1030,11 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
],
),
),
- actions: (() {
- final cancelButton = dialogButton(
- "Cancel",
- icon: Icon(Icons.close_rounded),
- onPressed: close,
- isOutline: true,
- );
- final removeButton = dialogButton(
- "Remove",
- icon: Icon(Icons.delete_outline_rounded),
- onPressed: () async {
- setState(() {
- errMsg0 = "";
- errMsg1 = "";
- });
- final ok =
- await bind.mainSetPermanentPasswordWithResult(password: "");
- if (!ok) {
- setState(() {
- errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
- });
- return;
- }
- close();
- },
- buttonStyle: ButtonStyle(
- backgroundColor: MaterialStatePropertyAll(Colors.red)),
- );
- final okButton = dialogButton(
- "OK",
- icon: Icon(Icons.done_rounded),
- onPressed: canSubmit ? submit : null,
- );
- if (!isDesktop && !isWebDesktop && localPasswordSet) {
- return [
- Align(
- alignment: Alignment.centerRight,
- child: FittedBox(
- fit: BoxFit.scaleDown,
- alignment: Alignment.centerRight,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- cancelButton,
- const SizedBox(width: 4),
- removeButton,
- const SizedBox(width: 4),
- okButton,
- ],
- ),
- ),
- ),
- ];
- }
- return [
- cancelButton,
- if (localPasswordSet) removeButton,
- okButton,
- ];
- })(),
- onSubmit: canSubmit ? submit : null,
+ actions: [
+ dialogButton("Cancel", onPressed: close, isOutline: true),
+ dialogButton("OK", onPressed: submit),
+ ],
+ onSubmit: submit,
onCancel: close,
);
});
diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart
index d1d620014..6e8f42d4e 100644
--- a/flutter/lib/desktop/pages/desktop_setting_page.dart
+++ b/flutter/lib/desktop/pages/desktop_setting_page.dart
@@ -458,46 +458,28 @@ class _GeneralState extends State<_General> {
return const Offstage();
}
- final hideStopService =
- bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
-
- return Obx(() {
- if (hideStopService && !serviceStop.value) {
- return const Offstage();
- }
-
- return _Card(title: 'Service', children: [
- _Button(serviceStop.value ? 'Start' : 'Stop', () {
- () async {
- serviceBtnEnabled.value = false;
- await start_service(serviceStop.value);
- // enable the button after 1 second
- Future.delayed(const Duration(seconds: 1), () {
- serviceBtnEnabled.value = true;
- });
- }();
- }, enabled: serviceBtnEnabled.value)
- ]);
- });
+ return _Card(title: 'Service', children: [
+ Obx(() => _Button(serviceStop.value ? 'Start' : 'Stop', () {
+ () async {
+ serviceBtnEnabled.value = false;
+ await start_service(serviceStop.value);
+ // enable the button after 1 second
+ Future.delayed(const Duration(seconds: 1), () {
+ serviceBtnEnabled.value = true;
+ });
+ }();
+ }, enabled: serviceBtnEnabled.value))
+ ]);
}
Widget other() {
- final showAutoUpdate = isWindows && bind.mainIsInstalled();
+ final showAutoUpdate =
+ isWindows && bind.mainIsInstalled() && !bind.isCustomClient();
final children = [
if (!isWeb && !bind.isIncomingOnly())
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
kOptionEnableConfirmClosingTabs,
isServer: false),
- if (!bind.isIncomingOnly())
- _OptionCheckBox(
- context,
- 'allow-remote-toolbar-docking-any-edge',
- kOptionAllowMultiEdgeToolbarDock,
- isServer: false,
- update: (_) {
- reloadAllWindows();
- },
- ),
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
if (!isWeb) wallpaper(),
if (!isWeb && !bind.isIncomingOnly()) ...[
@@ -575,36 +557,16 @@ class _GeneralState extends State<_General> {
],
],
];
-
- // Add client-side wakelock option for desktop platforms
- if (!bind.isIncomingOnly()) {
- children.add(_OptionCheckBox(
- context,
- 'keep-awake-during-outgoing-sessions-label',
- kOptionKeepAwakeDuringOutgoingSessions,
- isServer: false,
- ));
- }
-
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
children.add(_OptionCheckBox(
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
}
- if (!bind.isDisableAccount()) {
- children.add(_OptionCheckBox(
- context,
- 'note-at-conn-end-tip',
- kOptionAllowAskForNoteAtEndOfConnection,
- isServer: false,
- optSetter: (key, value) async {
- if (value && !gFFI.userModel.isLogin) {
- final res = await loginDialog();
- if (res != true) return;
- }
- await mainSetLocalBoolOption(key, value);
- },
- ));
- }
+ children.add(_OptionCheckBox(
+ context,
+ 'note-at-conn-end-tip',
+ kOptionAllowAskForNoteAtEndOfConnection,
+ isServer: false,
+ ));
return _Card(title: 'Other', children: children);
}
@@ -854,8 +816,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
permissions(context),
password(context),
_Card(title: '2FA', children: [tfa()]),
- if (!isChangeIdDisabled())
- _Card(title: 'ID', children: [changeId()]),
+ _Card(title: 'ID', children: [changeId()]),
more(context),
]),
),
@@ -1072,10 +1033,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
_OptionCheckBox(context, 'Enable blocking user input',
kOptionEnableBlockInput,
enabled: enabled, fakeValue: fakeValue),
- if (bind.mainSupportedPrivacyModeImpls() != '[]')
- _OptionCheckBox(
- context, 'Enable privacy mode', kOptionEnablePrivacyMode,
- enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable remote configuration modification',
kOptionAllowRemoteConfigModification,
enabled: enabled, fakeValue: fakeValue),
@@ -1123,13 +1080,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
if (value ==
passwordValues[passwordKeys
.indexOf(kUsePermanentPassword)] &&
- (await bind.mainGetCommon(
- key: "permanent-password-set")) !=
- "true") {
- if (isChangePermanentPasswordDisabled()) {
- await callback();
- return;
- }
+ (await bind.mainGetPermanentPassword())
+ .isEmpty) {
setPasswordDialog(notEmptyCallback: callback);
} else {
await callback();
@@ -1234,7 +1186,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
enabled: tmpEnabled && !locked),
if (usePassword) numericOneTimePassword,
if (usePassword) radios[1],
- if (usePassword && !isChangePermanentPasswordDisabled())
+ if (usePassword)
_SubButton('Set permanent password', setPasswordDialog,
permEnabled && !locked),
// if (usePassword)
@@ -1253,14 +1205,11 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
...directIp(context),
whitelist(),
...autoDisconnect(context),
- _OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
- kOptionKeepAwakeDuringIncomingSessions,
- reverse: false, enabled: enabled),
if (bind.mainIsInstalled())
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
'allow-only-conn-window-open',
reverse: false, enabled: enabled),
- if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin()
+ if (bind.mainIsInstalled()) unlockPin()
]);
}
@@ -2039,9 +1988,7 @@ class _AccountState extends State<_Account> {
Widget accountAction() {
return Obx(() => _Button(
- gFFI.userModel.userName.value.isEmpty
- ? 'Login'
- : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
+ gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
@@ -2050,65 +1997,24 @@ class _AccountState extends State<_Account> {
}
Widget useInfo() {
+ text(String key, String value) {
+ return Align(
+ alignment: Alignment.centerLeft,
+ child: SelectionArea(child: Text('${translate(key)}: $value'))
+ .marginSymmetric(vertical: 4),
+ );
+ }
+
return Obx(() => Offstage(
offstage: gFFI.userModel.userName.value.isEmpty,
- child: Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(10),
- ),
- child: Builder(builder: (context) {
- final avatarWidget = _buildUserAvatar();
- return Row(
- children: [
- if (avatarWidget != null) avatarWidget,
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- gFFI.userModel.displayNameOrUserName,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 2),
- SelectionArea(
- child: Text(
- '@${gFFI.userModel.userName.value}',
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- fontSize: 13,
- color:
- Theme.of(context).textTheme.bodySmall?.color,
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- );
- }),
+ child: Column(
+ children: [
+ text('Username', gFFI.userModel.userName.value),
+ // text('Group', gFFI.groupModel.groupName.value),
+ ],
),
)).marginOnly(left: 18, top: 16);
}
-
- Widget? _buildUserAvatar() {
- // Resolve relative avatar path at display time
- final avatar =
- bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
- return buildAvatarWidget(
- avatar: avatar,
- size: 44,
- );
- }
}
class _Checkbox extends StatefulWidget {
@@ -2196,9 +2102,7 @@ class _PluginState extends State<_Plugin> {
Widget accountAction() {
return Obx(() => _Button(
- gFFI.userModel.userName.value.isEmpty
- ? 'Login'
- : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
+ gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
@@ -2606,49 +2510,6 @@ class WaylandCard extends StatefulWidget {
class _WaylandCardState extends State {
final restoreTokenKey = 'wayland-restore-token';
- static const _kClearShortcutsInhibitorEventKey =
- 'clear-gnome-shortcuts-inhibitor-permission-res';
- final _clearShortcutsInhibitorFailedMsg = ''.obs;
- // Don't show the shortcuts permission reset button for now.
- // Users can change it manually:
- // "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts".
- // For resetting(clearing) the permission from the portal permission store, you can
- // use (replace with the RustDesk desktop file ID):
- // busctl --user call org.freedesktop.impl.portal.PermissionStore \
- // /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \
- // DeletePermission sss "gnome" "shortcuts-inhibitor" ""
- // On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually
- // the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop").
- //
- // We may add it back in the future if needed.
- final showResetInhibitorPermission = false;
-
- @override
- void initState() {
- super.initState();
- if (showResetInhibitorPermission) {
- platformFFI.registerEventHandler(
- _kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey,
- (evt) async {
- if (!mounted) return;
- if (evt['success'] == true) {
- setState(() {});
- } else {
- _clearShortcutsInhibitorFailedMsg.value =
- evt['msg'] as String? ?? 'Unknown error';
- }
- });
- }
- }
-
- @override
- void dispose() {
- if (showResetInhibitorPermission) {
- platformFFI.unregisterEventHandler(
- _kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey);
- }
- super.dispose();
- }
@override
Widget build(BuildContext context) {
@@ -2656,16 +2517,9 @@ class _WaylandCardState extends State {
future: bind.mainHandleWaylandScreencastRestoreToken(
key: restoreTokenKey, value: "get"),
hasData: (restoreToken) {
- final hasShortcutsPermission = showResetInhibitorPermission &&
- bind.mainGetCommonSync(
- key: "has-gnome-shortcuts-inhibitor-permission") ==
- "true";
-
final children = [
if (restoreToken.isNotEmpty)
_buildClearScreenSelection(context, restoreToken),
- if (hasShortcutsPermission)
- _buildClearShortcutsInhibitorPermission(context),
];
return Offstage(
offstage: children.isEmpty,
@@ -2710,50 +2564,6 @@ class _WaylandCardState extends State {
),
);
}
-
- Widget _buildClearShortcutsInhibitorPermission(BuildContext context) {
- onConfirm() {
- _clearShortcutsInhibitorFailedMsg.value = '';
- bind.mainSetCommon(
- key: "clear-gnome-shortcuts-inhibitor-permission", value: "");
- gFFI.dialogManager.dismissAll();
- }
-
- showConfirmMsgBox() => msgBoxCommon(
- gFFI.dialogManager,
- 'Confirmation',
- Text(
- translate('confirm-clear-shortcuts-inhibitor-permission-tip'),
- ),
- [
- dialogButton('OK', onPressed: onConfirm),
- dialogButton('Cancel',
- onPressed: () => gFFI.dialogManager.dismissAll())
- ]);
-
- return Column(children: [
- Obx(
- () => _clearShortcutsInhibitorFailedMsg.value.isEmpty
- ? Offstage()
- : Align(
- alignment: Alignment.topLeft,
- child: Text(_clearShortcutsInhibitorFailedMsg.value,
- style: DefaultTextStyle.of(context)
- .style
- .copyWith(color: Colors.red))
- .marginOnly(bottom: 10.0)),
- ),
- _Button(
- 'Reset keyboard shortcuts permission',
- showConfirmMsgBox,
- tip: 'clear-shortcuts-inhibitor-permission-tip',
- style: ButtonStyle(
- backgroundColor: MaterialStateProperty.all(
- Theme.of(context).colorScheme.error.withOpacity(0.75)),
- ),
- ),
- ]);
- }
}
// ignore: non_constant_identifier_names
@@ -2835,7 +2645,7 @@ Widget _lock(
]).marginSymmetric(vertical: 2)),
onPressed: () async {
final unlockPin = bind.mainGetUnlockPin();
- if (unlockPin.isEmpty || isUnlockPinDisabled()) {
+ if (unlockPin.isEmpty) {
bool checked = await callMainCheckSuperUserPermission();
if (checked) {
onUnlock();
diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart
index cf97351b3..6dc89d09f 100644
--- a/flutter/lib/desktop/pages/file_manager_page.dart
+++ b/flutter/lib/desktop/pages/file_manager_page.dart
@@ -17,6 +17,7 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
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';
@@ -85,7 +86,6 @@ class _FileManagerPageState extends State
final _dropMaskVisible = false.obs; // TODO impl drop mask
final _overlayKeyState = OverlayKeyState();
- final _uniqueKey = UniqueKey();
late FFI _ffi;
@@ -107,7 +107,9 @@ class _FileManagerPageState extends State
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
Get.put(_ffi, tag: 'ft_${widget.id}');
- WakelockManager.enable(_uniqueKey);
+ if (!isLinux) {
+ WakelockPlus.enable();
+ }
if (isWeb) {
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@@ -125,7 +127,9 @@ class _FileManagerPageState extends State
model.close().whenComplete(() {
_ffi.close();
_ffi.dialogManager.dismissAll();
- WakelockManager.disable(_uniqueKey);
+ if (!isLinux) {
+ WakelockPlus.disable();
+ }
Get.delete(tag: 'ft_${widget.id}');
});
WidgetsBinding.instance.removeObserver(this);
@@ -278,9 +282,11 @@ class _FileManagerPageState extends State
item.state != JobState.inProgress,
child: LinearPercentIndicator(
animateFromLastPercent: true,
- center: Text(item.percentText),
+ center: Text(
+ '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
+ ),
barRadius: Radius.circular(15),
- percent: item.percent,
+ percent: item.finishedSize / item.totalSize,
progressColor: MyTheme.accent,
backgroundColor: Theme.of(context).hoverColor,
lineHeight: kDesktopFileTransferRowHeight,
diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart
index 29e710bbc..e31196dc8 100644
--- a/flutter/lib/desktop/pages/remote_page.dart
+++ b/flutter/lib/desktop/pages/remote_page.dart
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:flutter_hbb/models/state_model.dart';
import '../../consts.dart';
@@ -15,7 +16,6 @@ import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/toolbar.dart';
import '../../models/model.dart';
-import '../../models/input_model.dart';
import '../../models/platform_model.dart';
import '../../common/shared_state.dart';
import '../../utils/image.dart';
@@ -85,16 +85,11 @@ class _RemotePageState extends State
late RxBool _zoomCursor;
late RxBool _remoteCursorMoved;
late RxBool _keyboardEnabled;
- final _uniqueKey = UniqueKey();
var _blockableOverlayState = BlockableOverlayState();
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
- // Debounce timer for pointer lock center updates during window events.
- // Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
- Timer? _pointerLockCenterDebounceTimer;
-
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
// to identify the toolbar instance and its callback function.
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
@@ -143,7 +138,9 @@ class _RemotePageState extends State
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
- WakelockManager.enable(_uniqueKey);
+ if (!isLinux) {
+ WakelockPlus.enable();
+ }
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
@@ -174,16 +171,6 @@ class _RemotePageState extends State
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController?.onSelected?.call(widget.id);
});
-
- // Register callback to cancel debounce timer when relative mouse mode is disabled
- _ffi.inputModel.onRelativeMouseModeDisabled =
- _cancelPointerLockCenterDebounceTimer;
- }
-
- /// Cancel the pointer lock center debounce timer
- void _cancelPointerLockCenterDebounceTimer() {
- _pointerLockCenterDebounceTimer?.cancel();
- _pointerLockCenterDebounceTimer = null;
}
@override
@@ -199,13 +186,6 @@ class _RemotePageState extends State
_rawKeyFocusNode.unfocus();
}
stateGlobal.isFocused.value = false;
-
- // When window loses focus, temporarily release relative mouse mode constraints
- // to allow user to interact with other applications normally.
- // The cursor will be re-hidden and re-centered when window regains focus.
- if (_ffi.inputModel.relativeMouseMode.value) {
- _ffi.inputModel.onWindowBlur();
- }
}
@override
@@ -216,12 +196,6 @@ class _RemotePageState extends State
_isWindowBlur = false;
}
stateGlobal.isFocused.value = true;
-
- // Restore relative mouse mode constraints when window regains focus.
- if (_ffi.inputModel.relativeMouseMode.value) {
- _rawKeyFocusNode.requestFocus();
- _ffi.inputModel.onWindowFocus();
- }
}
@override
@@ -232,59 +206,25 @@ class _RemotePageState extends State
if (isWindows) {
_isWindowBlur = false;
}
- WakelockManager.enable(_uniqueKey);
- // Update pointer lock center when window is restored
- _updatePointerLockCenterIfNeeded();
+ if (!isLinux) {
+ WakelockPlus.enable();
+ }
}
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
@override
void onWindowMaximize() {
super.onWindowMaximize();
- WakelockManager.enable(_uniqueKey);
- // Update pointer lock center when window is maximized
- _updatePointerLockCenterIfNeeded();
- }
-
- @override
- void onWindowResize() {
- super.onWindowResize();
- // Update pointer lock center when window is resized
- _updatePointerLockCenterIfNeeded();
- }
-
- @override
- void onWindowMove() {
- super.onWindowMove();
- // Update pointer lock center when window is moved
- _updatePointerLockCenterIfNeeded();
- }
-
- /// Update pointer lock center with debouncing to avoid excessive updates
- /// during rapid window move/resize events.
- void _updatePointerLockCenterIfNeeded() {
- if (!_ffi.inputModel.relativeMouseMode.value) return;
-
- // Cancel any pending update and schedule a new one (debounce pattern)
- _pointerLockCenterDebounceTimer?.cancel();
- _pointerLockCenterDebounceTimer = Timer(
- const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
- () {
- if (!mounted) return;
- if (_ffi.inputModel.relativeMouseMode.value) {
- _ffi.inputModel.updatePointerLockCenter();
- }
- },
- );
+ if (!isLinux) {
+ WakelockPlus.enable();
+ }
}
@override
void onWindowMinimize() {
super.onWindowMinimize();
- WakelockManager.disable(_uniqueKey);
- // Release cursor constraints when minimized
- if (_ffi.inputModel.relativeMouseMode.value) {
- _ffi.inputModel.onWindowBlur();
+ if (!isLinux) {
+ WakelockPlus.disable();
}
}
@@ -311,16 +251,6 @@ class _RemotePageState extends State
// https://github.com/flutter/flutter/issues/64935
super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
-
- // Defensive cleanup: ensure host system-key propagation is reset even if
- // MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
- if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
-
- _pointerLockCenterDebounceTimer?.cancel();
- _pointerLockCenterDebounceTimer = null;
- // Clear callback reference to prevent memory leaks and stale references
- _ffi.inputModel.onRelativeMouseModeDisabled = null;
- // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) {
// ensure we leave this session, this is a double check
@@ -338,7 +268,9 @@ class _RemotePageState extends State
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
}
- WakelockManager.disable(_uniqueKey);
+ if (!isLinux) {
+ await WakelockPlus.disable();
+ }
await Get.delete(tag: widget.id);
removeSharedStates(widget.id);
}
@@ -422,15 +354,10 @@ class _RemotePageState extends State
}
}(),
// Use Overlay to enable rebuild every time on menu button click.
- // Hide toolbar when relative mouse mode is active to prevent
- // cursor from escaping to toolbar area.
- Obx(() => _ffi.inputModel.relativeMouseMode.value
- ? const Offstage()
- : _ffi.ffiModel.pi.isSet.isTrue
- ? Overlay(initialEntries: [
- OverlayEntry(builder: remoteToolbar)
- ])
- : remoteToolbar(context)),
+ _ffi.ffiModel.pi.isSet.isTrue
+ ? Overlay(
+ initialEntries: [OverlayEntry(builder: remoteToolbar)])
+ : remoteToolbar(context),
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
],
),
@@ -498,7 +425,6 @@ class _RemotePageState extends State
//
}
}
-
// See [onWindowBlur].
if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) {
@@ -524,7 +450,6 @@ class _RemotePageState extends State
//
}
}
-
// See [onWindowBlur].
if (!isWindows) {
_ffi.inputModel.enterOrLeave(false);
@@ -572,39 +497,33 @@ class _RemotePageState extends State
Widget getBodyForDesktop(BuildContext context) {
var paints = [
- MouseRegion(
- onEnter: (evt) {
- if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
- },
- onExit: (evt) {
- if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
- },
- child: _ViewStyleUpdater(
- canvasModel: _ffi.canvasModel,
- inputModel: _ffi.inputModel,
- child: Builder(builder: (context) {
- final peerDisplay = CurrentDisplayState.find(widget.id);
- return Obx(
- () => _ffi.ffiModel.pi.isSet.isFalse
- ? Container(color: Colors.transparent)
- : Obx(() {
- _ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
- return ImagePaint(
- id: widget.id,
- zoomCursor: _zoomCursor,
- cursorOverImage: _cursorOverImage,
- keyboardEnabled: _keyboardEnabled,
- remoteCursorMoved: _remoteCursorMoved,
- listenerBuilder: (child) =>
- _buildRawTouchAndPointerRegion(
- child, enterView, leaveView),
- ffi: _ffi,
- );
- }),
- );
- }),
- ),
- )
+ MouseRegion(onEnter: (evt) {
+ if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
+ }, onExit: (evt) {
+ if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
+ }, child: LayoutBuilder(builder: (context, constraints) {
+ final c = Provider.of(context, listen: false);
+ Future.delayed(Duration.zero, () => c.updateViewStyle());
+ final peerDisplay = CurrentDisplayState.find(widget.id);
+ return Obx(
+ () => _ffi.ffiModel.pi.isSet.isFalse
+ ? Container(color: Colors.transparent)
+ : Obx(() {
+ widget.toolbarState.initShow(sessionId);
+ _ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
+ return ImagePaint(
+ id: widget.id,
+ zoomCursor: _zoomCursor,
+ cursorOverImage: _cursorOverImage,
+ keyboardEnabled: _keyboardEnabled,
+ remoteCursorMoved: _remoteCursorMoved,
+ listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
+ child, enterView, leaveView),
+ ffi: _ffi,
+ );
+ }),
+ );
+ }))
];
if (!_ffi.canvasModel.cursorEmbedded) {
@@ -633,63 +552,6 @@ class _RemotePageState extends State
bool get wantKeepAlive => true;
}
-/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
-/// and InputModel.updateImageWidgetSize() only when size actually changes.
-/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
-class _ViewStyleUpdater extends StatefulWidget {
- final CanvasModel canvasModel;
- final InputModel inputModel;
- final Widget child;
-
- const _ViewStyleUpdater({
- Key? key,
- required this.canvasModel,
- required this.inputModel,
- required this.child,
- }) : super(key: key);
-
- @override
- State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
-}
-
-class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
- Size? _lastSize;
- bool _callbackScheduled = false;
-
- @override
- Widget build(BuildContext context) {
- return LayoutBuilder(
- builder: (context, constraints) {
- final maxWidth = constraints.maxWidth;
- final maxHeight = constraints.maxHeight;
- // Guard against infinite constraints (e.g., unconstrained ancestor).
- if (!maxWidth.isFinite || !maxHeight.isFinite) {
- return widget.child;
- }
- final newSize = Size(maxWidth, maxHeight);
- if (_lastSize != newSize) {
- _lastSize = newSize;
- // Schedule the update for after the current frame to avoid setState during build.
- // Use _callbackScheduled flag to prevent accumulating multiple callbacks
- // when size changes rapidly before any callback executes.
- if (!_callbackScheduled) {
- _callbackScheduled = true;
- SchedulerBinding.instance.addPostFrameCallback((_) {
- _callbackScheduled = false;
- final currentSize = _lastSize;
- if (mounted && currentSize != null) {
- widget.canvasModel.updateViewStyle();
- widget.inputModel.updateImageWidgetSize(currentSize);
- }
- });
- }
- }
- return widget.child;
- },
- );
- }
-}
-
class ImagePaint extends StatefulWidget {
final FFI ffi;
final String id;
@@ -754,24 +616,21 @@ class _ImagePaintState extends State {
cursor: cursorOverImage.isTrue
? c.cursorEmbedded
? SystemMouseCursors.none
- // Hide cursor when relative mouse mode is active
- : widget.ffi.inputModel.relativeMouseMode.value
- ? SystemMouseCursors.none
- : keyboardEnabled.isTrue
- ? (() {
- if (remoteCursorMoved.isTrue) {
- _lastRemoteCursorMoved = true;
- return SystemMouseCursors.none;
- } else {
- if (_lastRemoteCursorMoved) {
- _lastRemoteCursorMoved = false;
- _firstEnterImage.value = true;
- }
- return _buildCustomCursor(
- context, getCursorScale());
- }
- }())
- : _buildDisabledCursor(context, getCursorScale())
+ : keyboardEnabled.isTrue
+ ? (() {
+ if (remoteCursorMoved.isTrue) {
+ _lastRemoteCursorMoved = true;
+ return SystemMouseCursors.none;
+ } else {
+ if (_lastRemoteCursorMoved) {
+ _lastRemoteCursorMoved = false;
+ _firstEnterImage.value = true;
+ }
+ return _buildCustomCursor(
+ context, getCursorScale());
+ }
+ }())
+ : _buildDisabledCursor(context, getCursorScale())
: MouseCursor.defer,
onHover: (evt) {},
child: child);
diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart
index ccd5935ce..6a9f1e89d 100644
--- a/flutter/lib/desktop/pages/remote_tab_page.dart
+++ b/flutter/lib/desktop/pages/remote_tab_page.dart
@@ -135,13 +135,7 @@ class _ConnectionTabPageState extends State {
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
- tail: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- _RelativeMouseModeHint(tabController: tabController),
- const AddButton(),
- ],
- ),
+ tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter,
@@ -257,11 +251,11 @@ class _ConnectionTabPageState extends State {
MenuEntryButton(
childBuilder: (TextStyle? style) => Obx(() => Text(
translate(
- toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
+ toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
style: style,
)),
proc: () {
- toolbarState.switchHide(sessionId);
+ toolbarState.switchShow(sessionId);
cancelFunc();
},
padding: padding,
@@ -380,8 +374,6 @@ class _ConnectionTabPageState extends State {
loopCloseWindow();
}
ConnectionTypeState.delete(id);
- // Clean up relative mouse mode state for this peer.
- stateGlobal.relativeMouseModeState.remove(id);
_update_remote_count();
}
@@ -556,69 +548,3 @@ class _ConnectionTabPageState extends State {
return returnValue;
}
}
-
-/// A widget that displays a hint in the tab bar when relative mouse mode is active.
-/// This helps users remember how to exit relative mouse mode.
-class _RelativeMouseModeHint extends StatelessWidget {
- final DesktopTabController tabController;
-
- const _RelativeMouseModeHint({Key? key, required this.tabController})
- : super(key: key);
-
- @override
- Widget build(BuildContext context) {
- return Obx(() {
- // Check if there are any tabs
- if (tabController.state.value.tabs.isEmpty) {
- return const SizedBox.shrink();
- }
-
- // Get current selected tab's RemotePage
- final selectedTabInfo = tabController.state.value.selectedTabInfo;
- if (selectedTabInfo.page is! RemotePage) {
- return const SizedBox.shrink();
- }
-
- final remotePage = selectedTabInfo.page as RemotePage;
- final String peerId = remotePage.id;
-
- // Use global state to check relative mouse mode (synced from InputModel).
- // This avoids timing issues with FFI registration.
- final isRelativeMouseMode =
- stateGlobal.relativeMouseModeState[peerId] ?? false;
-
- if (!isRelativeMouseMode) {
- return const SizedBox.shrink();
- }
-
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
- margin: const EdgeInsets.only(right: 8),
- decoration: BoxDecoration(
- color: Colors.orange.withOpacity(0.2),
- borderRadius: BorderRadius.circular(4),
- border: Border.all(color: Colors.orange.withOpacity(0.5)),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- Icons.mouse,
- size: 14,
- color: Colors.orange[700],
- ),
- const SizedBox(width: 4),
- Text(
- translate(
- 'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
- style: TextStyle(
- fontSize: 11,
- color: Colors.orange[700],
- ),
- ),
- ],
- ),
- );
- });
- }
-}
diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart
index 8bd7df08b..4ee29756f 100644
--- a/flutter/lib/desktop/pages/server_page.dart
+++ b/flutter/lib/desktop/pages/server_page.dart
@@ -462,7 +462,23 @@ class _CmHeaderState extends State<_CmHeader>
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- _buildClientAvatar().marginOnly(right: 10.0),
+ Container(
+ width: 70,
+ height: 70,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: str2color(client.name),
+ borderRadius: BorderRadius.circular(15.0),
+ ),
+ child: Text(
+ client.name[0],
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ fontSize: 55,
+ ),
+ ),
+ ).marginOnly(right: 10.0),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
@@ -566,36 +582,6 @@ class _CmHeaderState extends State<_CmHeader>
@override
bool get wantKeepAlive => true;
-
- Widget _buildClientAvatar() {
- return buildAvatarWidget(
- avatar: client.avatar,
- size: 70,
- borderRadius: 15,
- fallback: _buildInitialAvatar(),
- ) ??
- _buildInitialAvatar();
- }
-
- Widget _buildInitialAvatar() {
- return Container(
- width: 70,
- height: 70,
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: str2color(client.name),
- borderRadius: BorderRadius.circular(15.0),
- ),
- child: Text(
- client.name.isNotEmpty ? client.name[0] : '?',
- style: TextStyle(
- fontWeight: FontWeight.bold,
- color: Colors.white,
- fontSize: 55,
- ),
- ),
- );
- }
}
class _PrivilegeBoard extends StatefulWidget {
@@ -610,24 +596,19 @@ class _PrivilegeBoard extends StatefulWidget {
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
late final client = widget.client;
Widget buildPermissionIcon(bool enabled, IconData iconData,
- Function(bool)? onTap, String tooltipText,
- {required bool canModify}) {
+ Function(bool)? onTap, String tooltipText) {
return Tooltip(
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
waitDuration: Duration.zero,
child: Container(
decoration: BoxDecoration(
- color: enabled
- ? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
- : Colors.grey[700],
+ color: enabled ? MyTheme.accent : Colors.grey[700],
borderRadius: BorderRadius.circular(10.0),
),
padding: EdgeInsets.all(8.0),
child: InkWell(
- onTap: canModify
- ? () =>
- checkClickTime(widget.client.id, () => onTap?.call(!enabled))
- : null,
+ onTap: () =>
+ checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -648,9 +629,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
Widget build(BuildContext context) {
final crossAxisCount = 4;
final spacing = 10.0;
- final canModifyPermission =
- bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
- 'N';
return Container(
width: double.infinity,
height: 160.0,
@@ -697,7 +675,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
- canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@@ -712,7 +689,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
- canModify: canModifyPermission,
),
]
: [
@@ -729,7 +705,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable keyboard/mouse'),
- canModify: canModifyPermission,
),
buildPermissionIcon(
client.clipboard,
@@ -744,7 +719,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable clipboard'),
- canModify: canModifyPermission,
),
buildPermissionIcon(
client.audio,
@@ -759,7 +733,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
- canModify: canModifyPermission,
),
buildPermissionIcon(
client.file,
@@ -774,7 +747,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable file copy and paste'),
- canModify: canModifyPermission,
),
buildPermissionIcon(
client.restart,
@@ -789,7 +761,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable remote restart'),
- canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@@ -804,7 +775,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
- canModify: canModifyPermission,
),
// only windows support block input
if (isWindows)
@@ -821,23 +791,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable blocking user input'),
- canModify: canModifyPermission,
- ),
- if (bind.mainSupportedPrivacyModeImpls() != '[]')
- buildPermissionIcon(
- client.privacyMode,
- Icons.visibility_off,
- (enabled) {
- bind.cmSwitchPermission(
- connId: client.id,
- name: "privacy_mode",
- enabled: enabled);
- setState(() {
- client.privacyMode = enabled;
- });
- },
- translate('Enable privacy mode'),
- canModify: canModifyPermission,
)
],
),
diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart
index d38dc4a8b..17bd86eef 100644
--- a/flutter/lib/desktop/pages/terminal_page.dart
+++ b/flutter/lib/desktop/pages/terminal_page.dart
@@ -1,4 +1,3 @@
-import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
@@ -16,7 +15,6 @@ class TerminalPage extends StatefulWidget {
required this.tabController,
required this.isSharedPassword,
required this.terminalId,
- required this.tabKey,
this.forceRelay,
this.connToken,
}) : super(key: key);
@@ -27,9 +25,6 @@ class TerminalPage extends StatefulWidget {
final bool? isSharedPassword;
final String? connToken;
final int terminalId;
-
- /// Tab key for focus management, passed from parent to avoid duplicate construction
- final String tabKey;
final SimpleWrapper?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
@@ -44,22 +39,14 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State
with AutomaticKeepAliveClientMixin {
- static const EdgeInsets _defaultTerminalPadding =
- EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
-
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
- final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
- StreamSubscription? _tabStateSubscription;
@override
void initState() {
super.initState();
- // Listen for tab selection changes to request focus
- _tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
-
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
@@ -77,13 +64,6 @@ class _TerminalPageState extends State
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
- // Enable focus once terminal has valid dimensions (first valid resize)
- if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
- _terminalFocusNode.canRequestFocus = true;
- // Auto-focus if this tab is currently selected
- _requestFocusIfSelected();
- }
-
// Schedule the setState for the next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
@@ -119,67 +99,25 @@ class _TerminalPageState extends State
@override
void dispose() {
- // Cancel tab state subscription to prevent memory leak
- _tabStateSubscription?.cancel();
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
- _terminalFocusNode.dispose();
// Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id);
super.dispose();
}
- void _onTabStateChanged(DesktopTabState state) {
- // Check if this tab is now selected and request focus
- if (state.selected >= 0 && state.selected < state.tabs.length) {
- final selectedTab = state.tabs[state.selected];
- if (selectedTab.key == widget.tabKey && mounted) {
- _requestFocusIfSelected();
- }
- }
- }
-
- void _requestFocusIfSelected() {
- if (!mounted || !_terminalFocusNode.canRequestFocus) return;
- // Use post-frame callback to ensure widget is fully laid out in focus tree
- WidgetsBinding.instance.addPostFrameCallback((_) {
- // Re-check conditions after frame: mounted, focusable, still selected, not already focused
- if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
- final state = widget.tabController.state.value;
- if (state.selected >= 0 && state.selected < state.tabs.length) {
- if (state.tabs[state.selected].key == widget.tabKey) {
- _terminalFocusNode.requestFocus();
- }
- }
- });
- }
-
// This method ensures that the number of visible rows is an integer by computing the
// extra space left after dividing the available height by the height of a single
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
EdgeInsets _calculatePadding(double heightPx) {
- final cellHeight = _cellHeight;
- if (!heightPx.isFinite ||
- heightPx <= 0 ||
- cellHeight == null ||
- !cellHeight.isFinite ||
- cellHeight <= 0) {
- return _defaultTerminalPadding;
- }
- final rows = (heightPx / cellHeight).floor();
- if (rows <= 0) {
- return _defaultTerminalPadding;
- }
- final extraSpace = heightPx - rows * cellHeight;
- if (!extraSpace.isFinite || extraSpace < 0) {
- return _defaultTerminalPadding;
+ if (_cellHeight == null) {
+ return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
+ final rows = (heightPx / _cellHeight!).floor();
+ final extraSpace = heightPx - rows * _cellHeight!;
final topBottom = extraSpace / 2.0;
- return EdgeInsets.symmetric(
- horizontal: _defaultTerminalPadding.horizontal / 2,
- vertical: topBottom,
- );
+ return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
}
@override
@@ -193,9 +131,7 @@ class _TerminalPageState extends State
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
- focusNode: _terminalFocusNode,
- // Note: autofocus is not used here because focus is managed manually
- // via _onTabStateChanged() to handle tab switching properly.
+ autofocus: true,
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
diff --git a/flutter/lib/desktop/pages/terminal_tab_page.dart b/flutter/lib/desktop/pages/terminal_tab_page.dart
index 63289e94d..e06dee321 100644
--- a/flutter/lib/desktop/pages/terminal_tab_page.dart
+++ b/flutter/lib/desktop/pages/terminal_tab_page.dart
@@ -34,10 +34,6 @@ class _TerminalTabPageState extends State {
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
int _nextTerminalId = 1;
- // Lightweight idempotency guard for async close operations
- final Set _closingTabs = {};
- // When true, all session cleanup should persist (window-level close in progress)
- bool _windowClosing = false;
_TerminalTabPageState(Map params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -46,7 +42,6 @@ class _TerminalTabPageState extends State {
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
- tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: params['id'],
@@ -75,12 +70,28 @@ class _TerminalTabPageState extends State {
label: tabLabel,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
- onTabCloseButton: () => _closeTab(tabKey),
+ onTabCloseButton: () async {
+ if (await desktopTryShowTabAuditDialogCloseCancelled(
+ id: tabKey,
+ tabController: tabController,
+ )) {
+ return;
+ }
+ // Close the terminal session first
+ final ffi = TerminalConnectionManager.getExistingConnection(peerId);
+ if (ffi != null) {
+ final terminalModel = ffi.terminalModels[terminalId];
+ if (terminalModel != null) {
+ await terminalModel.closeTerminal();
+ }
+ }
+ // Then close the tab
+ tabController.closeBy(tabKey);
+ },
page: TerminalPage(
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
- tabKey: tabKey,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
@@ -90,161 +101,6 @@ class _TerminalTabPageState extends State {
);
}
- /// Unified tab close handler for all close paths (button, shortcut, programmatic).
- /// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
- Future _closeTab(String tabKey) async {
- // Idempotency guard: skip if already closing this tab
- if (_closingTabs.contains(tabKey)) return;
- _closingTabs.add(tabKey);
-
- try {
- // Snapshot peerTabCount BEFORE any await to avoid race with concurrent
- // _closeAllTabs clearing tabController (which would make the live count
- // drop to 0 and incorrectly trigger session persistence).
- // Note: the snapshot may become stale if other individual tabs are closed
- // during the audit dialog, but this is an acceptable trade-off.
- int? snapshotPeerTabCount;
- final parsed = _parseTabKey(tabKey);
- if (parsed != null) {
- final (peerId, _) = parsed;
- snapshotPeerTabCount = tabController.state.value.tabs.where((t) {
- final p = _parseTabKey(t.key);
- return p != null && p.$1 == peerId;
- }).length;
- }
-
- if (await desktopTryShowTabAuditDialogCloseCancelled(
- id: tabKey,
- tabController: tabController,
- )) {
- return;
- }
-
- // Close terminal session if not in persistent mode.
- // Wrapped separately so session cleanup failure never blocks UI tab removal.
- try {
- await _closeTerminalSessionIfNeeded(tabKey,
- peerTabCount: snapshotPeerTabCount);
- } catch (e) {
- debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
- }
- // Always close the tab from UI, regardless of session cleanup result
- tabController.closeBy(tabKey);
- } catch (e) {
- debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e');
- } finally {
- _closingTabs.remove(tabKey);
- }
- }
-
- /// Close all tabs with session cleanup.
- /// Used for window-level close operations (onDestroy, handleWindowCloseButton).
- /// UI tabs are removed immediately; session cleanup runs in parallel with a
- /// bounded timeout so window close is not blocked indefinitely.
- Future _closeAllTabs() async {
- _windowClosing = true;
- final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
- // Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
- // Keep the cleanup target lookup below synchronous before its first await:
- // it relies on the current frame still retaining each TerminalPage's FFI/model.
- tabController.clear();
- // Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
- // Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
- final futures = tabKeys
- .where((tabKey) => !_closingTabs.contains(tabKey))
- .map((tabKey) async {
- try {
- await _closeTerminalSessionIfNeeded(tabKey, persistAll: true);
- } catch (e) {
- debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
- }
- }).toList();
- if (futures.isNotEmpty) {
- await Future.wait(futures).timeout(
- const Duration(seconds: 4),
- onTimeout: () {
- debugPrint(
- '[TerminalTabPage] Session cleanup timed out for batch close');
- return [];
- },
- );
- }
- }
-
- /// Close the terminal session on server side based on persistent mode.
- ///
- /// [persistAll] controls behavior when persistent mode is enabled:
- /// - `true` (window close): persist all sessions, don't close any.
- /// - `false` (tab close): only persist the last session for the peer,
- /// close others so only the most recent disconnected session survives.
- ///
- /// Note: if [_windowClosing] is true, persistAll is forced to true so that
- /// in-flight _closeTab() calls don't accidentally close sessions that the
- /// window-close flow intends to preserve.
- Future _closeTerminalSessionIfNeeded(String tabKey,
- {bool persistAll = false, int? peerTabCount}) async {
- // If window close is in progress, override to persist all sessions
- // even if this call originated from an individual tab close.
- if (_windowClosing) {
- persistAll = true;
- }
- final parsed = _parseTabKey(tabKey);
- if (parsed == null) return;
- final (peerId, terminalId) = parsed;
-
- final ffi = TerminalConnectionManager.getExistingConnection(peerId);
- if (ffi == null) return;
-
- final isPersistent = bind.sessionGetToggleOptionSync(
- sessionId: ffi.sessionId,
- arg: kOptionTerminalPersistent,
- );
-
- if (isPersistent) {
- if (persistAll) {
- // Window close: persist all sessions
- return;
- }
- // Tab close: only persist if this is the last tab for this peer.
- // Use the snapshot value if provided (avoids race with concurrent tab removal).
- final effectivePeerTabCount = peerTabCount ??
- tabController.state.value.tabs.where((t) {
- final p = _parseTabKey(t.key);
- return p != null && p.$1 == peerId;
- }).length;
- if (effectivePeerTabCount <= 1) {
- // Last tab for this peer — persist the session
- return;
- }
- // Not the last tab — fall through to close the session
- }
-
- final terminalModel = ffi.terminalModels[terminalId];
- if (terminalModel != null) {
- // closeTerminal() has internal 3s timeout, no need for external timeout
- await terminalModel.closeTerminal();
- }
- }
-
- /// Parse tabKey (format: "peerId_terminalId") into its components.
- /// Note: peerId may contain underscores, so we use lastIndexOf('_').
- /// Returns null if tabKey format is invalid.
- (String peerId, int terminalId)? _parseTabKey(String tabKey) {
- final lastUnderscore = tabKey.lastIndexOf('_');
- if (lastUnderscore <= 0) {
- debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey');
- return null;
- }
- final terminalIdStr = tabKey.substring(lastUnderscore + 1);
- final terminalId = int.tryParse(terminalIdStr);
- if (terminalId == null) {
- debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey');
- return null;
- }
- final peerId = tabKey.substring(0, lastUnderscore);
- return (peerId, terminalId);
- }
-
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
final List> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
@@ -328,8 +184,7 @@ class _TerminalTabPageState extends State {
} else if (call.method == kWindowEventRestoreTerminalSessions) {
_restoreSessions(call.arguments);
} else if (call.method == "onDestroy") {
- // Clean up sessions before window destruction (bounded wait)
- await _closeAllTabs();
+ tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
@@ -339,10 +194,7 @@ class _TerminalTabPageState extends State {
final currentTab = tabController.state.value.selectedTabInfo;
assert(call.arguments is String,
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
- // Use lastIndexOf to handle peerIds containing underscores
- final lastUnderscore = currentTab.key.lastIndexOf('_');
- if (lastUnderscore > 0 &&
- currentTab.key.substring(0, lastUnderscore) == call.arguments) {
+ if (currentTab.key.startsWith(call.arguments)) {
windowOnTop(windowId());
return true;
}
@@ -371,34 +223,8 @@ class _TerminalTabPageState extends State {
final persistentSessions =
args['persistent_sessions'] as List? ?? [];
final sortedSessions = persistentSessions.whereType().toList()..sort();
- var peerId = args['peer_id'] as String? ?? '';
- if (peerId.isEmpty) {
- if (tabController.state.value.tabs.isEmpty ||
- tabController.state.value.selected >=
- tabController.state.value.tabs.length) {
- debugPrint('[TerminalTabPage] Skip restore: no selected tab');
- return;
- }
- final currentTab = tabController.state.value.selectedTabInfo;
- final parsed = _parseTabKey(currentTab.key);
- if (parsed == null) return;
- peerId = parsed.$1;
- }
- final existingTerminalIds = tabController.state.value.tabs
- .map((tab) => _parseTabKey(tab.key))
- .where((parsed) => parsed != null && parsed.$1 == peerId)
- .map((parsed) => parsed!.$2)
- .toSet();
- if (existingTerminalIds.isEmpty) {
- debugPrint(
- '[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
- return;
- }
for (final terminalId in sortedSessions) {
- if (!existingTerminalIds.add(terminalId)) {
- continue;
- }
- _addNewTerminal(peerId, terminalId: terminalId);
+ _addNewTerminalForCurrentPeer(terminalId: terminalId);
// A delay is required to ensure the UI has sufficient time to update
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
// may be called prematurely while the tab widget is still in the tab controller.
@@ -439,7 +265,7 @@ class _TerminalTabPageState extends State {
// macOS: Cmd+W (standard for close tab)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
- _closeTab(currentTab.key);
+ tabController.closeBy(currentTab.key);
return true;
}
} else if (!isMacOS &&
@@ -448,7 +274,7 @@ class _TerminalTabPageState extends State {
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
- _closeTab(currentTab.key);
+ tabController.closeBy(currentTab.key);
return true;
}
}
@@ -503,10 +329,7 @@ class _TerminalTabPageState extends State {
void _addNewTerminal(String peerId, {int? terminalId}) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
- (tab) {
- final last = tab.key.lastIndexOf('_');
- return last > 0 && tab.key.substring(0, last) == peerId;
- },
+ (tab) => tab.key.startsWith('$peerId\_'),
);
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
@@ -527,10 +350,11 @@ class _TerminalTabPageState extends State {
void _addNewTerminalForCurrentPeer({int? terminalId}) {
final currentTab = tabController.state.value.selectedTabInfo;
- final parsed = _parseTabKey(currentTab.key);
- if (parsed == null) return;
- final (peerId, _) = parsed;
- _addNewTerminal(peerId, terminalId: terminalId);
+ final parts = currentTab.key.split('_');
+ if (parts.isNotEmpty) {
+ final peerId = parts[0];
+ _addNewTerminal(peerId, terminalId: terminalId);
+ }
}
@override
@@ -544,9 +368,10 @@ class _TerminalTabPageState extends State {
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
- final parsed = _parseTabKey(key);
- if (parsed == null) return Container();
- final (peerId, _) = parsed;
+ // Extract peerId from tab key (format: "peerId_terminalId")
+ final parts = key.split('_');
+ if (parts.isEmpty) return Container();
+ final peerId = parts[0];
return _tabMenuBuilder(peerId, () {});
},
));
@@ -575,11 +400,6 @@ class _TerminalTabPageState extends State {
}
}
- Future _closeWindowFromConnection() async {
- await _closeAllTabs();
- await WindowController.fromWindowId(windowId()).close();
- }
-
int windowId() {
return widget.params["windowId"];
}
@@ -606,7 +426,7 @@ class _TerminalTabPageState extends State {
}
}
if (connLength <= 1) {
- await _closeAllTabs();
+ tabController.clear();
return true;
} else {
final bool res;
@@ -617,7 +437,7 @@ class _TerminalTabPageState extends State {
res = await closeConfirmDialog();
}
if (res) {
- await _closeAllTabs();
+ tabController.clear();
}
return res;
}
diff --git a/flutter/lib/desktop/pages/view_camera_page.dart b/flutter/lib/desktop/pages/view_camera_page.dart
index c45ec4d86..4be6fdc57 100644
--- a/flutter/lib/desktop/pages/view_camera_page.dart
+++ b/flutter/lib/desktop/pages/view_camera_page.dart
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:flutter_hbb/models/state_model.dart';
import '../../consts.dart';
@@ -76,7 +77,6 @@ class _ViewCameraPageState extends State
String keyboardMode = "legacy";
bool _isWindowBlur = false;
final _cursorOverImage = false.obs;
- final _uniqueKey = UniqueKey();
var _blockableOverlayState = BlockableOverlayState();
@@ -124,7 +124,9 @@ class _ViewCameraPageState extends State
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
- WakelockManager.enable(_uniqueKey);
+ if (!isLinux) {
+ WakelockPlus.enable();
+ }
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
@@ -183,20 +185,26 @@ class _ViewCameraPageState extends State
if (isWindows) {
_isWindowBlur = false;
}
- WakelockManager.enable(_uniqueKey);
+ if (!isLinux) {
+ WakelockPlus.enable();
+ }
}
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
@override
void onWindowMaximize() {
super.onWindowMaximize();
- WakelockManager.enable(_uniqueKey);
+ if (!isLinux) {
+ WakelockPlus.enable();
+ }
}
@override
void onWindowMinimize() {
super.onWindowMinimize();
- WakelockManager.disable(_uniqueKey);
+ if (!isLinux) {
+ WakelockPlus.disable();
+ }
}
@override
@@ -239,7 +247,9 @@ class _ViewCameraPageState extends State
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
}
- WakelockManager.disable(_uniqueKey);
+ if (!isLinux) {
+ await WakelockPlus.disable();
+ }
await Get.delete(tag: widget.id);
removeSharedStates(widget.id);
}
@@ -455,6 +465,7 @@ class _ViewCameraPageState extends State
() => _ffi.ffiModel.pi.isSet.isFalse
? Container(color: Colors.transparent)
: Obx(() {
+ widget.toolbarState.initShow(sessionId);
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint(
id: widget.id,
diff --git a/flutter/lib/desktop/pages/view_camera_tab_page.dart b/flutter/lib/desktop/pages/view_camera_tab_page.dart
index 36fa623ff..4c04cb8b8 100644
--- a/flutter/lib/desktop/pages/view_camera_tab_page.dart
+++ b/flutter/lib/desktop/pages/view_camera_tab_page.dart
@@ -250,11 +250,11 @@ class _ViewCameraTabPageState extends State {
MenuEntryButton(
childBuilder: (TextStyle? style) => Obx(() => Text(
translate(
- toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
+ toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
style: style,
)),
proc: () {
- toolbarState.switchHide(sessionId);
+ toolbarState.switchShow(sessionId);
cancelFunc();
},
padding: padding,
diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart
index 44a2dc1c7..bc3757f1e 100644
--- a/flutter/lib/desktop/widgets/remote_toolbar.dart
+++ b/flutter/lib/desktop/widgets/remote_toolbar.dart
@@ -28,229 +28,11 @@ import './kb_layout_type_chooser.dart';
import 'package:flutter_hbb/utils/scale.dart';
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
-enum _ToolbarEdge { top, right, bottom, left }
-
-_ToolbarEdge _parseToolbarEdge(String? s) {
- switch (s) {
- case 'right':
- return _ToolbarEdge.right;
- case 'bottom':
- return _ToolbarEdge.bottom;
- case 'left':
- return _ToolbarEdge.left;
- default:
- return _ToolbarEdge.top;
- }
-}
-
-String _toolbarEdgeToString(_ToolbarEdge e) {
- switch (e) {
- case _ToolbarEdge.top:
- return 'top';
- case _ToolbarEdge.right:
- return 'right';
- case _ToolbarEdge.bottom:
- return 'bottom';
- case _ToolbarEdge.left:
- return 'left';
- }
-}
-
-bool _isHorizontalEdge(_ToolbarEdge e) =>
- e == _ToolbarEdge.top || e == _ToolbarEdge.bottom;
-
-const _legacyRemoteMenubarDragX = 'remote-menubar-drag-x';
-
-double _clampToolbarFraction(double fraction, double left, double right) {
- if (fraction < left) fraction = left;
- if (fraction > right) fraction = right;
- return fraction;
-}
-
-Size _toolbarSizeForEdge(_ToolbarEdge edge, Size? measured) {
- final isHorizontal = _isHorizontalEdge(edge);
- final fallback = isHorizontal ? const Size(360, 40) : const Size(40, 360);
- final size = measured ?? fallback;
- final long = size.longestSide;
- final short = size.shortestSide;
- return Size(isHorizontal ? long : short, isHorizontal ? short : long);
-}
-
-Offset _toolbarOffsetForEdge({
- required _ToolbarEdge edge,
- required double fraction,
- required Size parentSize,
- required Size toolbarSize,
-}) {
- final xTravel = parentSize.width - toolbarSize.width;
- final yTravel = parentSize.height - toolbarSize.height;
- switch (edge) {
- case _ToolbarEdge.top:
- return Offset(xTravel * fraction, 0);
- case _ToolbarEdge.bottom:
- return Offset(xTravel * fraction, yTravel);
- case _ToolbarEdge.left:
- return Offset(0, yTravel * fraction);
- case _ToolbarEdge.right:
- return Offset(xTravel, yTravel * fraction);
- }
-}
-
-double _fractionForAlignedDrag({
- required double cursor,
- required double grabOffset,
- required double parentExtent,
- required double toolbarExtent,
- required double left,
- required double right,
-}) {
- final travelExtent = parentExtent - toolbarExtent;
- if (travelExtent <= 0) {
- return _clampToolbarFraction(0.5, left, right);
- }
- return _clampToolbarFraction(
- (cursor - grabOffset) / travelExtent, left, right);
-}
-
-({double left, double right}) _fractionBoundsForEdge(
- _ToolbarEdge edge,
- double left,
- double right,
-) {
- return _isHorizontalEdge(edge)
- ? (left: left, right: right)
- : (left: 0, right: 1);
-}
-
-String _toolbarRawFraction({
- required bool multiEdgeEnabled,
- required _ToolbarEdge edge,
- required String? savedFraction,
- required String? legacyFraction,
-}) {
- if (!multiEdgeEnabled) {
- return (legacyFraction != null && legacyFraction.isNotEmpty)
- ? legacyFraction
- : '0.5';
- }
- if (savedFraction != null && savedFraction.isNotEmpty) {
- return savedFraction;
- }
- if (edge == _ToolbarEdge.top &&
- legacyFraction != null &&
- legacyFraction.isNotEmpty) {
- return legacyFraction;
- }
- return '0.5';
-}
-
-// Returns the alignment for the wrapper Align that positions the entire
-// toolbar against the given edge at the given fraction along that edge.
-// Alignment uses [-1, 1] coordinates (0 = center).
-Alignment _alignmentForEdge(_ToolbarEdge edge, double fraction) {
- final f = fraction * 2 - 1;
- switch (edge) {
- case _ToolbarEdge.top:
- return Alignment(f, -1);
- case _ToolbarEdge.bottom:
- return Alignment(f, 1);
- case _ToolbarEdge.left:
- return Alignment(-1, f);
- case _ToolbarEdge.right:
- return Alignment(1, f);
- }
-}
-
-// The drag handle hangs off the side of the toolbar facing away from the
-// docked edge, so the icons themselves sit flush against that edge.
-BorderRadius _collapseHandleBorderRadius(_ToolbarEdge edge) {
- const r = Radius.circular(5);
- switch (edge) {
- case _ToolbarEdge.top:
- return const BorderRadius.vertical(bottom: r);
- case _ToolbarEdge.bottom:
- return const BorderRadius.vertical(top: r);
- case _ToolbarEdge.left:
- return const BorderRadius.horizontal(right: r);
- case _ToolbarEdge.right:
- return const BorderRadius.horizontal(left: r);
- }
-}
-
-int _monitorMenuQuarterTurns(_ToolbarEdge edge) {
- switch (edge) {
- case _ToolbarEdge.left:
- return 1;
- case _ToolbarEdge.right:
- return 3;
- case _ToolbarEdge.top:
- case _ToolbarEdge.bottom:
- return 0;
- }
-}
-
-IconData _toolbarCollapseIcon(_ToolbarEdge edge, bool isCollapsed) {
- switch (edge) {
- case _ToolbarEdge.top:
- return isCollapsed ? Icons.expand_more : Icons.expand_less;
- case _ToolbarEdge.bottom:
- return isCollapsed ? Icons.expand_less : Icons.expand_more;
- case _ToolbarEdge.left:
- return isCollapsed ? Icons.chevron_right : Icons.chevron_left;
- case _ToolbarEdge.right:
- return isCollapsed ? Icons.chevron_left : Icons.chevron_right;
- }
-}
-
-class _ToolbarDockingOptions {
- _ToolbarDockingOptions({
- required this.edge,
- required this.fraction,
- required this.multiEdgeEnabled,
- });
-
- _ToolbarEdge edge;
- double fraction;
- bool multiEdgeEnabled;
-}
-
-final _toolbarDockingOptionsBySession = {};
-
-String _toolbarDockingCacheKey(SessionID sessionId) => sessionId.toString();
-
-_ToolbarDockingOptions? _cachedToolbarDockingOptions(SessionID sessionId) =>
- _toolbarDockingOptionsBySession[_toolbarDockingCacheKey(sessionId)];
-
-void _cacheToolbarDockingOptions({
- required SessionID sessionId,
- required _ToolbarEdge edge,
- required double fraction,
- required bool multiEdgeEnabled,
-}) {
- final key = _toolbarDockingCacheKey(sessionId);
- final cached = _toolbarDockingOptionsBySession[key];
- if (cached == null) {
- _toolbarDockingOptionsBySession[key] = _ToolbarDockingOptions(
- edge: edge,
- fraction: fraction,
- multiEdgeEnabled: multiEdgeEnabled,
- );
- return;
- }
- cached.edge = edge;
- cached.fraction = fraction;
- cached.multiEdgeEnabled = multiEdgeEnabled;
-}
-
class ToolbarState {
late RxBool _pin;
- RxBool collapse = false.obs;
- RxBool hide = false.obs;
-
- // Track initialization state to prevent flickering
- final RxBool initialized = false.obs;
- bool _isInitializing = false;
+ bool isShowInited = false;
+ RxBool show = false.obs;
ToolbarState() {
_pin = RxBool(false);
@@ -271,39 +53,19 @@ class ToolbarState {
bool get pin => _pin.value;
- /// Initialize all toolbar states from session options.
- /// This should be called once when the toolbar is first created.
- Future init(SessionID sessionId) async {
- if (initialized.value || _isInitializing) return;
- _isInitializing = true;
-
- try {
- // Load both states in parallel for better performance
- final results = await Future.wait([
- bind.sessionGetToggleOption(
- sessionId: sessionId, arg: kOptionCollapseToolbar),
- bind.sessionGetToggleOption(
- sessionId: sessionId, arg: kOptionHideToolbar),
- ]);
-
- collapse.value = results[0] ?? false;
- hide.value = results[1] ?? false;
- } finally {
- _isInitializing = false;
- initialized.value = true;
- }
- }
-
- switchCollapse(SessionID sessionId) async {
+ switchShow(SessionID sessionId) async {
bind.sessionToggleOption(
sessionId: sessionId, value: kOptionCollapseToolbar);
- collapse.value = !collapse.value;
+ show.value = !show.value;
}
- // Switch hide state for entire toolbar visibility
- switchHide(SessionID sessionId) async {
- bind.sessionToggleOption(sessionId: sessionId, value: kOptionHideToolbar);
- hide.value = !hide.value;
+ initShow(SessionID sessionId) async {
+ if (!isShowInited) {
+ show.value = !(await bind.sessionGetToggleOption(
+ sessionId: sessionId, arg: kOptionCollapseToolbar) ??
+ false);
+ isShowInited = true;
+ }
}
switchPin() async {
@@ -464,26 +226,8 @@ class RemoteToolbar extends StatefulWidget {
class _RemoteToolbarState extends State {
late Debouncer _debouncerHide;
bool _isCursorOverImage = false;
- final _fraction = 0.5.obs;
- final _edge = _ToolbarEdge.top.obs;
+ final _fractionX = 0.5.obs;
final _dragging = false.obs;
- // Live drag preview: where the toolbar would dock if the user dropped now.
- final _previewEdge = Rxn<_ToolbarEdge>();
- final _previewFraction = Rxn();
- // Measured size of the live toolbar, so the preview ghost matches reality
- // (collapsed handle vs expanded toolbar). Updated after every layout pass.
- final _toolbarSize = Rxn();
- final _toolbarKey = GlobalKey(debugLabel: 'remote_toolbar_root');
- // When false (default), the toolbar stays on the top edge and the drag
- // handle just slides it horizontally — preserving long-standing UX while
- // still fixing the bug where dragging only moved the handle. When true,
- // the user has opted into multi-edge docking with nearest-edge snap.
- // Kept in sync after settings-triggered rebuilds.
- final _multiEdgeEnabled = false.obs;
- final _dockingOptionsInitialized = false.obs;
- bool _pendingDockingOptionSync = false;
- int _dockingOptionSyncSerial = 0;
- int _dragEpoch = 0;
int get windowId => stateGlobal.windowId;
@@ -493,8 +237,7 @@ class _RemoteToolbarState extends State {
// setState(() {});
}
- RxBool get collapse => widget.state.collapse;
- RxBool get hide => widget.state.hide;
+ RxBool get show => widget.state.show;
bool get pin => widget.state.pin;
PeerInfo get pi => widget.ffi.ffiModel.pi;
@@ -505,146 +248,16 @@ class _RemoteToolbarState extends State {
void _minimize() async =>
await WindowController.fromWindowId(windowId).minimize();
- Future _syncDockingOptions({required bool force}) async {
- final syncSerial = ++_dockingOptionSyncSerial;
- if (_dragging.isTrue) {
- _deferDockingOptionsSync();
- return;
- }
- final dragEpoch = _dragEpoch;
-
- // Use the canonical helper so the option's documented default semantics
- // apply (allow-* prefix => default false). Keeping it raw-string would
- // diverge from how _OptionCheckBox displays the same key.
- final multiEdgeEnabled =
- mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock);
- final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId);
- if (cached == null && pi.isSet.isFalse) {
- return;
- }
- final hadDockingOptions = cached != null;
- final wasMultiEdgeEnabled =
- cached?.multiEdgeEnabled ?? _multiEdgeEnabled.value;
- if (!force &&
- hadDockingOptions &&
- wasMultiEdgeEnabled == multiEdgeEnabled) {
- _pendingDockingOptionSync = false;
- return;
- }
-
- final savedFraction = await bind.sessionGetOption(
- sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarFraction);
- // Backward compat: legacy horizontal-only position.
- final legacyFraction = await bind.sessionGetOption(
- sessionId: widget.ffi.sessionId, arg: _legacyRemoteMenubarDragX);
- if (!mounted || syncSerial != _dockingOptionSyncSerial) return;
-
- var nextEdge = _edge.value;
- var savedFractionForNextEdge = savedFraction;
- var keepCurrentPosition = false;
- if (!multiEdgeEnabled) {
- nextEdge = _ToolbarEdge.top;
- } else if (force || wasMultiEdgeEnabled || cached == null) {
- final edgeStr = await bind.sessionGetOption(
- sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarEdge);
- if (!mounted || syncSerial != _dockingOptionSyncSerial) return;
- nextEdge = _parseToolbarEdge(edgeStr);
- } else {
- // The setting changed from top-only to multi-edge while this toolbar is
- // already visible. Keep its current position instead of jumping to the
- // last saved multi-edge dock.
- nextEdge = cached.edge;
- savedFractionForNextEdge = cached.fraction.toString();
- keepCurrentPosition = true;
- }
-
- final rawFraction = _toolbarRawFraction(
- multiEdgeEnabled: multiEdgeEnabled,
- edge: nextEdge,
- savedFraction: savedFractionForNextEdge,
- legacyFraction: legacyFraction,
- );
- // Clamp to the saved drag-bound contract so a corrupted or out-of-range
- // saved value can't bypass it until the user drags again.
- final dragLeft = double.tryParse(
- bind.mainGetLocalOption(key: kOptionRemoteMenubarDragLeft)) ??
- 0.0;
- final dragRight = double.tryParse(
- bind.mainGetLocalOption(key: kOptionRemoteMenubarDragRight)) ??
- 1.0;
- final fractionBounds =
- _fractionBoundsForEdge(nextEdge, dragLeft, dragRight);
- final nextFraction = (double.tryParse(rawFraction) ?? 0.5)
- .clamp(fractionBounds.left, fractionBounds.right)
- .toDouble();
- if (!mounted || syncSerial != _dockingOptionSyncSerial) return;
- if (_dragging.isTrue || dragEpoch != _dragEpoch) {
- _deferDockingOptionsSync();
- return;
- }
- _edge.value = nextEdge;
- _fraction.value = nextFraction;
- _multiEdgeEnabled.value = multiEdgeEnabled;
- _dockingOptionsInitialized.value = true;
- _cacheToolbarDockingOptions(
- sessionId: widget.ffi.sessionId,
- edge: nextEdge,
- fraction: nextFraction,
- multiEdgeEnabled: multiEdgeEnabled,
- );
- _pendingDockingOptionSync = false;
- if (!multiEdgeEnabled || keepCurrentPosition) {
- bind.sessionPeerOption(
- sessionId: widget.ffi.sessionId,
- name: kOptionRemoteMenubarEdge,
- value: _toolbarEdgeToString(nextEdge),
- );
- bind.sessionPeerOption(
- sessionId: widget.ffi.sessionId,
- name: kOptionRemoteMenubarFraction,
- value: nextFraction.toString(),
- );
- }
- }
-
- void _deferDockingOptionsSync() {
- _pendingDockingOptionSync = true;
- if (_dragging.isFalse) {
- _syncDockingOptionsAfterDragIfNeeded();
- }
- }
-
- void _markToolbarDragEpoch() {
- ++_dragEpoch;
- }
-
- void _syncDockingOptionsAfterDragIfNeeded() {
- if (!_pendingDockingOptionSync) return;
- WidgetsBinding.instance.addPostFrameCallback((_) async {
- await _syncDockingOptions(force: false);
- });
- }
-
@override
initState() {
super.initState();
- final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId);
- final multiEdgeEnabled =
- mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock);
- final shouldResetToTop =
- cached != null && cached.multiEdgeEnabled && !multiEdgeEnabled;
- if (cached != null && !shouldResetToTop) {
- _edge.value = cached.edge;
- _fraction.value = cached.fraction;
- _multiEdgeEnabled.value = multiEdgeEnabled;
- _dockingOptionsInitialized.value = true;
- }
-
WidgetsBinding.instance.addPostFrameCallback((_) async {
- await _syncDockingOptions(force: cached == null || shouldResetToTop);
- // Initialize toolbar states (collapse, hide) from session options
- widget.state.init(widget.ffi.sessionId);
+ _fractionX.value = double.tryParse(await bind.sessionGetOption(
+ sessionId: widget.ffi.sessionId,
+ arg: 'remote-menubar-drag-x') ??
+ '0.5') ??
+ 0.5;
});
_debouncerHide = Debouncer(
@@ -663,146 +276,62 @@ class _RemoteToolbarState extends State {
});
}
- @override
- void didUpdateWidget(covariant RemoteToolbar oldWidget) {
- super.didUpdateWidget(oldWidget);
- WidgetsBinding.instance.addPostFrameCallback((_) async {
- await _syncDockingOptions(force: false);
- });
- }
-
_debouncerHideProc(int v) {
- if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) {
- collapse.value = true;
+ if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) {
+ show.value = false;
}
}
@override
dispose() {
- ++_dockingOptionSyncSerial;
- widget.onEnterOrLeaveImageCleaner(identityHashCode(this));
super.dispose();
+
+ widget.onEnterOrLeaveImageCleaner(identityHashCode(this));
}
@override
Widget build(BuildContext context) {
- return Obx(() {
- // Wait for initialization to complete to prevent flickering
- if (!widget.state.initialized.value ||
- !_dockingOptionsInitialized.value) {
- return const SizedBox.shrink();
- }
- // If toolbar is hidden, return empty widget
- if (hide.value) {
- return const SizedBox.shrink();
- }
- final edge = _edge.value;
- final isHorizontal = _isHorizontalEdge(edge);
-
- // Measure the live toolbar after every layout so the preview ghost can
- // match its actual footprint (collapsed handle vs expanded toolbar).
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (_dragging.isTrue) return;
- final ro = _toolbarKey.currentContext?.findRenderObject();
- if (ro is RenderBox && ro.hasSize) {
- final s = ro.size;
- if (_toolbarSize.value != s) _toolbarSize.value = s;
- }
- });
-
- final toolbar = Align(
- alignment: _alignmentForEdge(edge, _fraction.value),
- child: KeyedSubtree(
- key: _toolbarKey,
- child: collapse.isFalse
- ? _buildToolbar(context, edge, isHorizontal)
- : _buildDraggableCollapse(context, edge, isHorizontal),
- ),
- );
-
- // Always return the Stack — even when not dragging — so the toolbar's
- // position in the Element tree stays stable. Wrapping/unwrapping it
- // mid-drag was killing the Draggable's gesture state.
- return Stack(
- fit: StackFit.expand,
- children: [
- IgnorePointer(
- child: Obx(() {
- final pe = _previewEdge.value;
- final pf = _previewFraction.value;
- if (!_dragging.isTrue || pe == null || pf == null) {
- return const SizedBox.shrink();
- }
- return _buildDragPreview(context, pe, pf, _toolbarSize.value);
- }),
- ),
- toolbar,
- ],
- );
- });
- }
-
- Widget _buildDragPreview(BuildContext context, _ToolbarEdge edge,
- double fraction, Size? measured) {
- final color = Theme.of(context).colorScheme.primary;
- // Use the measured live toolbar size so collapsed vs expanded looks
- // right. The current orientation may differ from the preview orientation
- // (e.g. dragging a top-docked toolbar toward the left edge), so swap the
- // long/short axes when previewing a different orientation.
- final previewSize = _toolbarSizeForEdge(edge, measured);
return Align(
- alignment: _alignmentForEdge(edge, fraction),
- child: Container(
- width: previewSize.width,
- height: previewSize.height,
- decoration: BoxDecoration(
- color: color.withOpacity(0.10),
- borderRadius: BorderRadius.circular(6),
- border: Border.all(color: color.withOpacity(0.55), width: 1.5),
- ),
- ),
+ alignment: Alignment.topCenter,
+ child: Obx(() => show.value
+ ? _buildToolbar(context)
+ : _buildDraggableShowHide(context)),
);
}
- Widget _buildDraggableCollapse(
- BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
+ Widget _buildDraggableShowHide(BuildContext context) {
return Obx(() {
- if (collapse.isFalse && _dragging.isFalse) {
+ if (show.isTrue && _dragging.isFalse) {
triggerAutoHide();
}
- final borderRadius = _collapseHandleBorderRadius(edge);
- return Offstage(
- offstage: _dragging.isTrue,
- child: Material(
- elevation: _ToolbarTheme.elevation,
- shadowColor: MyTheme.color(context).shadow,
- borderRadius: borderRadius,
- child: _DraggableShowHide(
- id: widget.id,
- sessionId: widget.ffi.sessionId,
- dragging: _dragging,
- fraction: _fraction,
- edge: _edge,
- previewEdge: _previewEdge,
- previewFraction: _previewFraction,
- toolbarSize: _toolbarSize,
- markDragEpoch: _markToolbarDragEpoch,
- syncDockingOptionsAfterDragIfNeeded:
- _syncDockingOptionsAfterDragIfNeeded,
- isHorizontal: isHorizontal,
- multiEdgeEnabled: _multiEdgeEnabled.value,
- toolbarState: widget.state,
- setFullscreen: _setFullscreen,
- setMinimize: _minimize,
+ final borderRadius = BorderRadius.vertical(
+ bottom: Radius.circular(5),
+ );
+ return Align(
+ alignment: FractionalOffset(_fractionX.value, 0),
+ child: Offstage(
+ offstage: _dragging.isTrue,
+ child: Material(
+ elevation: _ToolbarTheme.elevation,
+ shadowColor: MyTheme.color(context).shadow,
borderRadius: borderRadius,
+ child: _DraggableShowHide(
+ id: widget.id,
+ sessionId: widget.ffi.sessionId,
+ dragging: _dragging,
+ fractionX: _fractionX,
+ toolbarState: widget.state,
+ setFullscreen: _setFullscreen,
+ setMinimize: _minimize,
+ borderRadius: borderRadius,
+ ),
),
),
);
});
}
- Widget _buildToolbar(
- BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
+ Widget _buildToolbar(BuildContext context) {
final List toolbarItems = [];
toolbarItems.add(_PinMenu(state: widget.state));
if (!isWebDesktop) {
@@ -810,13 +339,11 @@ class _RemoteToolbarState extends State {
}
toolbarItems.add(Obx(() {
- if ((PrivacyModeState.find(widget.id).isEmpty ||
- allowDisplaySwitchInPrivacyMode(pi)) &&
+ if (PrivacyModeState.find(widget.id).isEmpty &&
pi.displaysCount.value > 1) {
return _MonitorMenu(
id: widget.id,
ffi: widget.ffi,
- edge: edge,
setRemoteState: widget.setRemoteState);
} else {
return Offstage();
@@ -842,53 +369,37 @@ class _RemoteToolbarState extends State {
if (!isWeb) toolbarItems.add(_RecordMenu());
toolbarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi));
final toolbarBorderRadius = BorderRadius.all(Radius.circular(4.0));
- // innerAxis: how the toolbar icons themselves flow.
- // outerAxis: how the toolbar block and the handle stack against each other
- // (perpendicular to the dock edge, so the handle hangs off the interior face).
- final innerAxis = isHorizontal ? Axis.horizontal : Axis.vertical;
- final outerAxis = isHorizontal ? Axis.vertical : Axis.horizontal;
- final spacer = isHorizontal
- ? SizedBox(width: _ToolbarTheme.buttonHMargin * 2)
- : SizedBox(height: _ToolbarTheme.buttonHMargin * 2);
- final toolbarMaterial = Material(
- elevation: _ToolbarTheme.elevation,
- shadowColor: MyTheme.color(context).shadow,
- borderRadius: toolbarBorderRadius,
- color: Theme.of(context)
- .menuBarTheme
- .style
- ?.backgroundColor
- ?.resolve(MaterialState.values.toSet()),
- child: SingleChildScrollView(
- scrollDirection: innerAxis,
- child: Theme(
- data: themeData(),
- child: _ToolbarTheme.borderWrapper(
- context,
- Flex(
- direction: innerAxis,
- mainAxisSize: MainAxisSize.min,
- children: [
- spacer,
- ...toolbarItems,
- spacer,
- ],
- ),
- toolbarBorderRadius),
- ),
- ),
- );
- final handle = _buildDraggableCollapse(context, edge, isHorizontal);
- // The handle hangs off the interior face of the toolbar (away from the
- // docked edge), centered along that face by the Flex's default cross-axis
- // alignment, so the icons themselves sit flush against the docked edge.
- final children = (edge == _ToolbarEdge.top || edge == _ToolbarEdge.left)
- ? [toolbarMaterial, handle]
- : [handle, toolbarMaterial];
- return Flex(
- direction: outerAxis,
+ return Column(
mainAxisSize: MainAxisSize.min,
- children: children,
+ children: [
+ Material(
+ elevation: _ToolbarTheme.elevation,
+ shadowColor: MyTheme.color(context).shadow,
+ borderRadius: toolbarBorderRadius,
+ color: Theme.of(context)
+ .menuBarTheme
+ .style
+ ?.backgroundColor
+ ?.resolve(MaterialState.values.toSet()),
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Theme(
+ data: themeData(),
+ child: _ToolbarTheme.borderWrapper(
+ context,
+ Row(
+ children: [
+ SizedBox(width: _ToolbarTheme.buttonHMargin * 2),
+ ...toolbarItems,
+ SizedBox(width: _ToolbarTheme.buttonHMargin * 2)
+ ],
+ ),
+ toolbarBorderRadius),
+ ),
+ ),
+ ),
+ _buildDraggableShowHide(context),
+ ],
);
}
@@ -967,13 +478,11 @@ class _MobileActionMenu extends StatelessWidget {
class _MonitorMenu extends StatelessWidget {
final String id;
final FFI ffi;
- final _ToolbarEdge edge;
final Function(VoidCallback) setRemoteState;
const _MonitorMenu({
Key? key,
required this.id,
required this.ffi,
- required this.edge,
required this.setRemoteState,
}) : super(key: key);
@@ -984,17 +493,9 @@ class _MonitorMenu extends StatelessWidget {
!isWeb && ffi.ffiModel.pi.isSupportMultiDisplay;
@override
- Widget build(BuildContext context) {
- final child = showMonitorsToolbar
- ? buildMultiMonitorMenu(context)
- : Obx(() => buildMonitorMenu(context));
- final quarterTurns = _monitorMenuQuarterTurns(edge);
- if (quarterTurns == 0) return child;
- return RotatedBox(
- quarterTurns: quarterTurns,
- child: child,
- );
- }
+ Widget build(BuildContext context) => showMonitorsToolbar
+ ? buildMultiMonitorMenu(context)
+ : Obx(() => buildMonitorMenu(context));
Widget buildMonitorMenu(BuildContext context) {
final width = SimpleWrapper(0);
@@ -1126,8 +627,7 @@ class _MonitorMenu extends StatelessWidget {
}
final scale = _ToolbarTheme.buttonSize / rect.height * 0.75;
- final height = rect.height * scale;
- final startY = (_ToolbarTheme.buttonSize - height) * 0.5;
+ final startY = (_ToolbarTheme.buttonSize - rect.height * scale) * 0.5;
final startX = startY;
final children = [];
@@ -1170,7 +670,7 @@ class _MonitorMenu extends StatelessWidget {
width.value = rect.width * scale + startX * 2;
return SizedBox(
width: width.value,
- height: height + startY * 2,
+ height: rect.height * scale + startY * 2,
child: Stack(
children: children,
),
@@ -1459,10 +959,10 @@ class _DisplayMenuState extends State<_DisplayMenu> {
toggles(),
];
// privacy mode
- final privacyModeState = PrivacyModeState.find(id);
if (ffi.connType == ConnType.defaultConn &&
- (pi.features.privacyMode || privacyModeState.isNotEmpty) &&
- (ffiModel.keyboard || privacyModeState.isNotEmpty)) {
+ ffiModel.keyboard &&
+ pi.features.privacyMode) {
+ final privacyModeState = PrivacyModeState.find(id);
final privacyModeList =
toolbarPrivacyMode(privacyModeState, context, id, ffi);
if (privacyModeList.length == 1) {
@@ -2228,23 +1728,13 @@ class _KeyboardMenu extends StatelessWidget {
Widget build(BuildContext context) {
var ffiModel = Provider.of(context);
if (!ffiModel.keyboard) return Offstage();
- toolbarToggles() {
- final toggles = toolbarKeyboardToggles(ffi)
- .map((e) => CkbMenuButton(
- value: e.value,
- onChanged: e.onChanged,
- child: e.child,
- ffi: ffi) as Widget)
- .toList();
- if (toggles.isNotEmpty) {
- toggles.add(Divider());
- }
- return toggles;
- }
-
+ toolbarToggles() => toolbarKeyboardToggles(ffi)
+ .map((e) => CkbMenuButton(
+ value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi))
+ .toList();
return _IconSubmenuButton(
tooltip: 'Keyboard Settings',
- svg: "assets/keyboard_mouse.svg",
+ svg: "assets/keyboard.svg",
ffi: ffi,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
@@ -2324,18 +1814,8 @@ class _KeyboardMenu extends StatelessWidget {
continue;
}
- if (pi.isWayland) {
- // Legacy mode is hidden on desktop control side because dead keys
- // don't work properly on Wayland. When the control side is mobile,
- // Legacy mode is used automatically (mobile always sends Legacy events).
- if (mode.key == kKeyLegacyMode) {
- continue;
- }
- // Translate mode requires server >= 1.4.6.
- if (mode.key == kKeyTranslateMode &&
- versionCmp(pi.version, '1.4.6') < 0) {
- continue;
- }
+ if (pi.isWayland && mode.key != kKeyMapMode) {
+ continue;
}
var text = translate(mode.menu);
@@ -2981,18 +2461,7 @@ class RdoMenuButton extends StatelessWidget {
class _DraggableShowHide extends StatefulWidget {
final String id;
final SessionID sessionId;
- final RxDouble fraction;
- final Rx<_ToolbarEdge> edge;
- final Rxn<_ToolbarEdge> previewEdge;
- final Rxn previewFraction;
- final Rxn toolbarSize;
- final VoidCallback markDragEpoch;
- final VoidCallback syncDockingOptionsAfterDragIfNeeded;
- final bool isHorizontal;
- // Whether multi-edge docking is enabled for this session (toggled in
- // Settings -> Other). When false, the drag handle slides the toolbar
- // horizontally on the top edge and never switches edges.
- final bool multiEdgeEnabled;
+ final RxDouble fractionX;
final RxBool dragging;
final ToolbarState toolbarState;
final BorderRadius borderRadius;
@@ -3004,15 +2473,7 @@ class _DraggableShowHide extends StatefulWidget {
Key? key,
required this.id,
required this.sessionId,
- required this.fraction,
- required this.edge,
- required this.previewEdge,
- required this.previewFraction,
- required this.toolbarSize,
- required this.markDragEpoch,
- required this.syncDockingOptionsAfterDragIfNeeded,
- required this.isHorizontal,
- required this.multiEdgeEnabled,
+ required this.fractionX,
required this.dragging,
required this.toolbarState,
required this.setFullscreen,
@@ -3025,14 +2486,12 @@ class _DraggableShowHide extends StatefulWidget {
}
class _DraggableShowHideState extends State<_DraggableShowHide> {
+ Offset position = Offset.zero;
+ Size size = Size.zero;
double left = 0.0;
double right = 1.0;
- Offset? _lastPointerDown;
- Offset? _dragGrabOffset;
- double? _dragLongAxisGrabOffset;
- Size? _dragToolbarSize;
- RxBool get collapse => widget.toolbarState.collapse;
+ RxBool get show => widget.toolbarState.show;
@override
initState() {
@@ -3056,174 +2515,41 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
}
}
- // Bias applied to the currently-previewed edge so a drag hovering between
- // two edges doesn't flicker. Only relevant when multi-edge is enabled.
- static const double _switchHysteresisPx = 50.0;
-
- _ToolbarEdge _nearestToolbarEdge(Offset cursor, Size mediaSize) {
- if (!widget.multiEdgeEnabled) return widget.edge.value;
-
- double rawDist(_ToolbarEdge e) {
- switch (e) {
- case _ToolbarEdge.top:
- return cursor.dy;
- case _ToolbarEdge.bottom:
- return mediaSize.height - cursor.dy;
- case _ToolbarEdge.left:
- return cursor.dx;
- case _ToolbarEdge.right:
- return mediaSize.width - cursor.dx;
- }
- }
-
- final previewed = widget.previewEdge.value;
- var winner = widget.edge.value;
- var best = double.infinity;
- for (final e in _ToolbarEdge.values) {
- final biased =
- e == previewed ? rawDist(e) - _switchHysteresisPx : rawDist(e);
- if (biased < best) {
- best = biased;
- winner = e;
- }
- }
- return winner;
- }
-
- void _ensureDragGrabOffset(Offset cursor) {
- if (_dragGrabOffset != null) return;
- final mediaSize = MediaQueryData.fromView(View.of(context)).size;
- final toolbarSize =
- _toolbarSizeForEdge(widget.edge.value, widget.toolbarSize.value);
- _dragToolbarSize = toolbarSize;
- final toolbarOffset = _toolbarOffsetForEdge(
- edge: widget.edge.value,
- fraction: widget.fraction.value,
- parentSize: mediaSize,
- toolbarSize: toolbarSize,
- );
- _dragGrabOffset = cursor - toolbarOffset;
- _dragLongAxisGrabOffset = _isHorizontalEdge(widget.edge.value)
- ? _dragGrabOffset?.dx
- : _dragGrabOffset?.dy;
- }
-
- double _dragGrabOffsetForEdge(_ToolbarEdge edge, Size toolbarSize) {
- final offset = _dragLongAxisGrabOffset ?? 0;
- final extent =
- _isHorizontalEdge(edge) ? toolbarSize.width : toolbarSize.height;
- return _clampToolbarFraction(offset, 0, extent);
- }
-
- void _updatePreview(Offset cursor) {
- _ensureDragGrabOffset(cursor);
- final mediaSize = MediaQueryData.fromView(View.of(context)).size;
- final winner = _nearestToolbarEdge(cursor, mediaSize);
- widget.previewEdge.value = winner;
-
- final toolbarSize = _toolbarSizeForEdge(winner, _dragToolbarSize);
- final grabOffset = _dragGrabOffsetForEdge(winner, toolbarSize);
- final double frac;
- if (winner == _ToolbarEdge.top || winner == _ToolbarEdge.bottom) {
- frac = _fractionForAlignedDrag(
- cursor: cursor.dx,
- grabOffset: grabOffset,
- parentExtent: mediaSize.width,
- toolbarExtent: toolbarSize.width,
- left: left,
- right: right,
- );
- } else {
- final fractionBounds = _fractionBoundsForEdge(winner, left, right);
- frac = _fractionForAlignedDrag(
- cursor: cursor.dy,
- grabOffset: grabOffset,
- parentExtent: mediaSize.height,
- toolbarExtent: toolbarSize.height,
- left: fractionBounds.left,
- right: fractionBounds.right,
- );
- }
- widget.previewFraction.value = frac;
- }
-
- void _resetDragTracking() {
- _lastPointerDown = null;
- _dragGrabOffset = null;
- _dragLongAxisGrabOffset = null;
- _dragToolbarSize = null;
- }
-
- void _commitPreview() {
- final newEdge = widget.previewEdge.value;
- final frac = widget.previewFraction.value;
- widget.previewEdge.value = null;
- widget.previewFraction.value = null;
- widget.dragging.value = false;
- widget.markDragEpoch();
- _resetDragTracking();
- widget.syncDockingOptionsAfterDragIfNeeded();
- if (newEdge == null || frac == null) return;
- widget.edge.value = newEdge;
- widget.fraction.value = frac;
- _cacheToolbarDockingOptions(
- sessionId: widget.sessionId,
- edge: newEdge,
- fraction: frac,
- multiEdgeEnabled: widget.multiEdgeEnabled,
- );
- bind.sessionPeerOption(
- sessionId: widget.sessionId,
- name: kOptionRemoteMenubarEdge,
- value: _toolbarEdgeToString(newEdge),
- );
- bind.sessionPeerOption(
- sessionId: widget.sessionId,
- name: kOptionRemoteMenubarFraction,
- value: frac.toString(),
- );
- if (widget.multiEdgeEnabled) {
- return;
- }
- bind.sessionPeerOption(
- sessionId: widget.sessionId,
- name: _legacyRemoteMenubarDragX,
- value: frac.toString(),
- );
- }
-
Widget _buildDraggable(BuildContext context) {
- return Listener(
- onPointerDown: (event) => _lastPointerDown = event.position,
- child: Draggable(
- // When multi-edge docking is off the toolbar stays on the top edge,
- // so lock the feedback to horizontal motion — otherwise the handle
- // floats away from the top while dragging and the toolbar looks
- // unmoored. When multi-edge is on we need 2D drag for snap-to-edge.
- axis: widget.multiEdgeEnabled ? null : Axis.horizontal,
- child: Icon(
- widget.isHorizontal ? Icons.drag_indicator : Icons.drag_handle,
- size: 20,
- color: MyTheme.color(context).drag_indicator,
- ),
- feedback: widget,
- onDragStarted: () {
- widget.markDragEpoch();
- final pointerDown = _lastPointerDown;
- if (pointerDown != null) {
- _ensureDragGrabOffset(pointerDown);
- }
- widget.dragging.value = true;
- // Seed the preview at the current docked edge/fraction so something
- // shows the instant the drag begins, before the first onDragUpdate.
- widget.previewEdge.value = widget.edge.value;
- widget.previewFraction.value = widget.fraction.value;
- },
- onDragUpdate: (details) {
- _updatePreview(details.globalPosition);
- },
- onDragEnd: (_) => _commitPreview(),
+ return Draggable(
+ axis: Axis.horizontal,
+ child: Icon(
+ Icons.drag_indicator,
+ size: 20,
+ color: MyTheme.color(context).drag_indicator,
),
+ feedback: widget,
+ onDragStarted: (() {
+ final RenderObject? renderObj = context.findRenderObject();
+ if (renderObj != null) {
+ final RenderBox renderBox = renderObj as RenderBox;
+ size = renderBox.size;
+ position = renderBox.localToGlobal(Offset.zero);
+ }
+ widget.dragging.value = true;
+ }),
+ onDragEnd: (details) {
+ final mediaSize = MediaQueryData.fromView(View.of(context)).size;
+ widget.fractionX.value +=
+ (details.offset.dx - position.dx) / (mediaSize.width - size.width);
+ if (widget.fractionX.value < left) {
+ widget.fractionX.value = left;
+ }
+ if (widget.fractionX.value > right) {
+ widget.fractionX.value = right;
+ }
+ bind.sessionPeerOption(
+ sessionId: widget.sessionId,
+ name: 'remote-menubar-drag-x',
+ value: widget.fractionX.value.toString(),
+ );
+ widget.dragging.value = false;
+ },
);
}
@@ -3253,9 +2579,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
);
}
- final axis = widget.isHorizontal ? Axis.horizontal : Axis.vertical;
- final child = Flex(
- direction: axis,
+ final child = Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDraggable(context),
@@ -3290,20 +2614,20 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
)),
buttonWrapper(
() => setState(() {
- widget.toolbarState.switchCollapse(widget.sessionId);
+ widget.toolbarState.switchShow(widget.sessionId);
}),
Obx((() => Tooltip(
- message: translate(
- collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'),
+ message:
+ translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
child: Icon(
- _toolbarCollapseIcon(widget.edge.value, collapse.isTrue),
+ show.isTrue ? Icons.expand_less : Icons.expand_more,
size: iconSize,
),
))),
),
if (isWebDesktop)
Obx(() {
- if (collapse.isFalse) {
+ if (show.isTrue) {
return Offstage();
} else {
return buttonWrapper(
@@ -3338,8 +2662,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
borderRadius: widget.borderRadius,
),
child: SizedBox(
- height: widget.isHorizontal ? 20 : null,
- width: widget.isHorizontal ? null : 20,
+ height: 20,
child: child,
),
),
diff --git a/flutter/lib/desktop/widgets/tabbar_widget.dart b/flutter/lib/desktop/widgets/tabbar_widget.dart
index 9ef7d38d9..cf601557a 100644
--- a/flutter/lib/desktop/widgets/tabbar_widget.dart
+++ b/flutter/lib/desktop/widgets/tabbar_widget.dart
@@ -99,7 +99,6 @@ class DesktopTabController {
/// index, key
Function(int, String)? onRemoved;
Function(String)? onSelected;
- Future Function()? onCloseWindow;
DesktopTabController(
{required this.tabType, this.onRemoved, this.onSelected});
@@ -593,13 +592,14 @@ class _DesktopTabState extends State
}
Widget _buildBar() {
- final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: GestureDetector(
// custom double tap handler
- onTap: !isIncomingHomePage && showMaximize
+ onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
+ showMaximize
? () {
final current = DateTime.now().millisecondsSinceEpoch;
final elapsed = current - _lastClickTime;
@@ -610,7 +610,7 @@ class _DesktopTabState extends State
.then((value) => stateGlobal.setMaximized(value));
}
}
- : (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
+ : null,
onPanStart: (_) => startDragging(isMainWindow),
onPanCancel: () {
// We want to disable dragging of the tab area in the tab bar.
diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart
index 1e793bca7..c7b183d35 100644
--- a/flutter/lib/mobile/pages/file_manager_page.dart
+++ b/flutter/lib/mobile/pages/file_manager_page.dart
@@ -5,6 +5,7 @@ import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
@@ -71,7 +72,6 @@ class _FileManagerPageState extends State {
showLocal ? model.localController : model.remoteController;
FileDirectory get currentDir => currentFileController.directory.value;
DirectoryOptions get currentOptions => currentFileController.options.value;
- final _uniqueKey = UniqueKey();
@override
void initState() {
@@ -86,7 +86,7 @@ class _FileManagerPageState extends State {
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
- WakelockManager.enable(_uniqueKey);
+ WakelockPlus.enable();
}
@override
@@ -94,7 +94,7 @@ class _FileManagerPageState extends State {
model.close().whenComplete(() {
gFFI.close();
gFFI.dialogManager.dismissAll();
- WakelockManager.disable(_uniqueKey);
+ WakelockPlus.disable();
});
model.jobController.clear();
super.dispose();
@@ -355,21 +355,15 @@ class _FileManagerPageState extends State {
return Offstage();
}
- // Find the first job that is in progress (the one actually transferring data)
- // Rust backend processes jobs sequentially, so the first inProgress job is the active one
- final activeJob = jobTable
- .firstWhereOrNull((job) => job.state == JobState.inProgress) ??
- jobTable.last;
-
- switch (activeJob.state) {
+ switch (jobTable.last.state) {
case JobState.inProgress:
return BottomSheetBody(
leading: CircularProgressIndicator(),
title: translate("Waiting"),
text:
- "${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
+ "${translate("Speed")}: ${readableFileSize(jobTable.last.speed)}/s",
onCanceled: () {
- model.jobController.cancelJob(activeJob.id);
+ model.jobController.cancelJob(jobTable.last.id);
jobTable.clear();
},
);
@@ -377,7 +371,7 @@ class _FileManagerPageState extends State {
return BottomSheetBody(
leading: Icon(Icons.check),
title: "${translate("Successful")}!",
- text: activeJob.display(),
+ text: jobTable.last.display(),
onCanceled: () => jobTable.clear(),
);
case JobState.error:
diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart
index 74a5af45c..dd783055a 100644
--- a/flutter/lib/mobile/pages/remote_page.dart
+++ b/flutter/lib/mobile/pages/remote_page.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -13,6 +14,7 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
import '../../common.dart';
import '../../common/widgets/overlay.dart';
@@ -64,8 +66,9 @@ class _RemotePageState extends State with WidgetsBindingObserver {
bool _showGestureHelp = false;
String _value = '';
Orientation? _currentOrientation;
- final _uniqueKey = UniqueKey();
- Timer? _iosKeyboardWorkaroundTimer;
+ double _viewInsetsBottom = 0;
+
+ Timer? _timerDidChangeMetrics;
final _blockableOverlayState = BlockableOverlayState();
@@ -102,7 +105,9 @@ class _RemotePageState extends State with WidgetsBindingObserver {
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
- WakelockManager.enable(_uniqueKey);
+ if (!isWeb) {
+ WakelockPlus.enable();
+ }
_physicalFocusNode.requestFocus();
gFFI.inputModel.listenToMouse(true);
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
@@ -137,11 +142,13 @@ class _RemotePageState extends State with WidgetsBindingObserver {
_physicalFocusNode.dispose();
await gFFI.close();
_timer?.cancel();
- _iosKeyboardWorkaroundTimer?.cancel();
+ _timerDidChangeMetrics?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
- WakelockManager.disable(_uniqueKey);
+ if (!isWeb) {
+ await WakelockPlus.disable();
+ }
await keyboardSubscription.cancel();
removeSharedStates(widget.id);
// `on_voice_call_closed` should be called when the connection is ended.
@@ -163,6 +170,26 @@ class _RemotePageState extends State with WidgetsBindingObserver {
gFFI.invokeMethod("try_sync_clipboard");
}
+ @override
+ void didChangeMetrics() {
+ // If the soft keyboard is visible and the canvas has been changed(panned or scaled)
+ // Don't try reset the view style and focus the cursor.
+ if (gFFI.cursorModel.lastKeyboardIsVisible &&
+ gFFI.canvasModel.isMobileCanvasChanged) {
+ return;
+ }
+
+ final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
+ _timerDidChangeMetrics?.cancel();
+ _timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
+ // We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
+ if (newBottom != _viewInsetsBottom) {
+ gFFI.canvasModel.mobileFocusCanvasCursor();
+ _viewInsetsBottom = newBottom;
+ }
+ });
+ }
+
// to-do: It should be better to use transparent color instead of the bgColor.
// But for now, the transparent color will cause the canvas to be white.
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
@@ -184,24 +211,7 @@ class _RemotePageState extends State with WidgetsBindingObserver {
gFFI.ffiModel.pi.version.isNotEmpty) {
gFFI.invokeMethod("enable_soft_keyboard", false);
}
-
- // Workaround for iOS: physical keyboard input fails after virtual keyboard is hidden
- // https://github.com/flutter/flutter/issues/39900
- // https://github.com/rustdesk/rustdesk/discussions/11843#discussioncomment-13499698 - Virtual keyboard issue
- if (isIOS) {
- _iosKeyboardWorkaroundTimer?.cancel();
- _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () {
- if (!mounted) return;
- _physicalFocusNode.unfocus();
- _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () {
- if (!mounted) return;
- _physicalFocusNode.requestFocus();
- });
- });
- }
} else {
- _iosKeyboardWorkaroundTimer?.cancel();
- _iosKeyboardWorkaroundTimer = null;
_timer?.cancel();
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
@@ -426,10 +436,12 @@ class _RemotePageState extends State with WidgetsBindingObserver {
}
return Container(
color: MyTheme.canvasColor,
- child: RawTouchGestureDetectorRegion(
- child: getBodyForMobile(),
- ffi: gFFI,
- ),
+ child: inputModel.isPhysicalMouse.value
+ ? getBodyForMobile()
+ : RawTouchGestureDetectorRegion(
+ child: getBodyForMobile(),
+ ffi: gFFI,
+ ),
);
}),
),
@@ -557,9 +569,7 @@ class _RemotePageState extends State with WidgetsBindingObserver {
}
bool get showCursorPaint =>
- !gFFI.ffiModel.isPeerAndroid &&
- !gFFI.canvasModel.cursorEmbedded &&
- !gFFI.inputModel.relativeMouseMode.value;
+ !gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded;
Widget getBodyForMobile() {
final keyboardIsVisible = keyboardVisibilityController.isVisible;
@@ -798,7 +808,6 @@ class _RemotePageState extends State with WidgetsBindingObserver {
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
},
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
- inputModel: gFFI.inputModel,
)));
}
@@ -1183,8 +1192,7 @@ void showOptions(
List privacyModeList = [];
// privacy mode
final privacyModeState = PrivacyModeState.find(id);
- if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
- privacyModeState.isNotEmpty) {
+ if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
if (privacyModeList.length == 1) {
displayToggles.add(privacyModeList[0]);
diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart
index cd3f97a53..ed4fe4d98 100644
--- a/flutter/lib/mobile/pages/server_page.dart
+++ b/flutter/lib/mobile/pages/server_page.dart
@@ -61,13 +61,12 @@ class _DropDownAction extends StatelessWidget {
final isAllowNumericOneTimePassword =
gFFI.serverModel.allowNumericOneTimePassword;
return [
- if (!isChangeIdDisabled())
- PopupMenuItem(
- enabled: gFFI.serverModel.connectStatus > 0,
- value: "changeID",
- child: Text(translate("Change ID")),
- ),
- if (!isChangeIdDisabled()) const PopupMenuDivider(),
+ PopupMenuItem(
+ enabled: gFFI.serverModel.connectStatus > 0,
+ value: "changeID",
+ child: Text(translate("Change ID")),
+ ),
+ const PopupMenuDivider(),
PopupMenuItem(
value: 'AcceptSessionsViaPassword',
child: listTile(
@@ -88,8 +87,7 @@ class _DropDownAction extends StatelessWidget {
),
if (showPasswordOption) const PopupMenuDivider(),
if (showPasswordOption &&
- verificationMethod != kUseTemporaryPassword &&
- !isChangePermanentPasswordDisabled())
+ verificationMethod != kUseTemporaryPassword)
PopupMenuItem(
value: "setPermanentPassword",
child: Text(translate("Set permanent password")),
@@ -150,12 +148,7 @@ class _DropDownAction extends StatelessWidget {
}
if (value == kUsePermanentPassword &&
- (await bind.mainGetCommon(key: "permanent-password-set")) !=
- "true") {
- if (isChangePermanentPasswordDisabled()) {
- callback();
- return;
- }
+ (await bind.mainGetPermanentPassword()).isEmpty) {
setPasswordDialog(notEmptyCallback: callback);
} else {
callback();
@@ -583,20 +576,10 @@ class _PermissionCheckerState extends State {
Widget build(BuildContext context) {
final serverModel = Provider.of(context);
final hasAudioPermission = androidVersion >= 30;
- final hideStopService = isAndroid &&
- bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
- final allowPermChangeInAcceptWindow = option2bool(
- kOptionEnablePermChangeInAcceptWindow,
- bind.mainGetBuildinOption(
- key: kOptionEnablePermChangeInAcceptWindow,
- ));
- final permissionChangeLocked = isAndroid &&
- serverModel.clients.any((c) => !c.disconnected) &&
- !allowPermChangeInAcceptWindow;
return PaddingCard(
title: translate("Permissions"),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
- serverModel.mediaOk && !hideStopService
+ serverModel.mediaOk
? ElevatedButton.icon(
style: ButtonStyle(
backgroundColor:
@@ -606,30 +589,21 @@ class _PermissionCheckerState extends State {
label: Text(translate("Stop service")))
.marginOnly(bottom: 8)
: SizedBox.shrink(),
- if (!hideStopService || !serverModel.mediaOk)
- PermissionRow(
- translate("Screen Capture"),
- serverModel.mediaOk,
- !serverModel.mediaOk &&
- gFFI.userModel.userName.value.isEmpty &&
- bind.mainGetLocalOption(key: "show-scam-warning") != "N"
- ? () => showScamWarning(context, serverModel)
- : serverModel.toggleService),
PermissionRow(
- translate("Input Control"),
- serverModel.inputOk,
- serverModel.toggleInput,
- ),
- PermissionRow(
- translate("Transfer file"),
- serverModel.fileOk,
- serverModel.toggleFile,
- enabled: !permissionChangeLocked,
- ),
+ translate("Screen Capture"),
+ serverModel.mediaOk,
+ !serverModel.mediaOk &&
+ gFFI.userModel.userName.value.isEmpty &&
+ bind.mainGetLocalOption(key: "show-scam-warning") != "N"
+ ? () => showScamWarning(context, serverModel)
+ : serverModel.toggleService),
+ PermissionRow(translate("Input Control"), serverModel.inputOk,
+ serverModel.toggleInput),
+ PermissionRow(translate("Transfer file"), serverModel.fileOk,
+ serverModel.toggleFile),
hasAudioPermission
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
- serverModel.toggleAudio,
- enabled: !permissionChangeLocked)
+ serverModel.toggleAudio)
: Row(children: [
Icon(Icons.info_outline).marginOnly(right: 15),
Expanded(
@@ -638,25 +612,19 @@ class _PermissionCheckerState extends State {
style: const TextStyle(color: MyTheme.darkGray),
))
]),
- PermissionRow(
- translate("Enable clipboard"),
- serverModel.clipboardOk,
- serverModel.toggleClipboard,
- enabled: !permissionChangeLocked,
- ),
+ PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
+ serverModel.toggleClipboard),
]));
}
}
class PermissionRow extends StatelessWidget {
- const PermissionRow(this.name, this.isOk, this.onPressed,
- {Key? key, this.enabled = true})
+ const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
: super(key: key);
final String name;
final bool isOk;
final VoidCallback onPressed;
- final bool enabled;
@override
Widget build(BuildContext context) {
@@ -665,11 +633,9 @@ class PermissionRow extends StatelessWidget {
contentPadding: EdgeInsets.all(0),
title: Text(name),
value: isOk,
- onChanged: enabled
- ? (bool value) {
- onPressed();
- }
- : null);
+ onChanged: (bool value) {
+ onPressed();
+ });
}
}
@@ -682,8 +648,9 @@ class ConnectionManager extends StatelessWidget {
return Column(
children: serverModel.clients
.map((client) => PaddingCard(
- title: translate(
- client.isFileTransfer ? "Transfer file" : "Share screen"),
+ title: translate(client.isFileTransfer
+ ? "Transfer file"
+ : "Share screen"),
titleIcon: client.isFileTransfer
? Icon(Icons.folder_outlined)
: Icon(Icons.mobile_screen_share),
@@ -869,7 +836,13 @@ class ClientInfo extends StatelessWidget {
flex: -1,
child: Padding(
padding: const EdgeInsets.only(right: 12),
- child: _buildAvatar(context))),
+ child: CircleAvatar(
+ backgroundColor: str2color(
+ client.name,
+ Theme.of(context).brightness == Brightness.light
+ ? 255
+ : 150),
+ child: Text(client.name[0])))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -882,20 +855,6 @@ class ClientInfo extends StatelessWidget {
),
]));
}
-
- Widget _buildAvatar(BuildContext context) {
- final fallback = CircleAvatar(
- backgroundColor: str2color(client.name,
- Theme.of(context).brightness == Brightness.light ? 255 : 150),
- child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
- );
- return buildAvatarWidget(
- avatar: client.avatar,
- size: 40,
- fallback: fallback,
- ) ??
- fallback;
- }
}
void androidChannelInit() {
diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart
index 509260636..395b77962 100644
--- a/flutter/lib/mobile/pages/settings_page.dart
+++ b/flutter/lib/mobile/pages/settings_page.dart
@@ -71,7 +71,6 @@ class _SettingsState extends State with WidgetsBindingObserver {
var _ignoreBatteryOpt = false;
var _enableStartOnBoot = false;
var _checkUpdateOnStartup = false;
- var _showTerminalExtraKeys = false;
var _floatingWindowDisabled = false;
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
var _enableAbr = false;
@@ -100,7 +99,6 @@ class _SettingsState extends State with WidgetsBindingObserver {
var _enableIpv6Punch = false;
var _isUsingPublicServer = false;
var _allowAskForNoteAtEndOfConnection = false;
- var _preventSleepWhileConnected = true;
_SettingsState() {
_enableAbr = option2bool(
@@ -141,10 +139,6 @@ class _SettingsState extends State with WidgetsBindingObserver {
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
_allowAskForNoteAtEndOfConnection =
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
- _preventSleepWhileConnected =
- mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
- _showTerminalExtraKeys =
- mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
}
@override
@@ -608,23 +602,6 @@ class _SettingsState extends State with WidgetsBindingObserver {
);
}
- enhancementsTiles.add(
- SettingsTile.switchTile(
- initialValue: _showTerminalExtraKeys,
- title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
- Text(translate('Show terminal extra keys')),
- ]),
- onToggle: (bool v) async {
- await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v);
- final newValue =
- mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
- setState(() {
- _showTerminalExtraKeys = newValue;
- });
- },
- ),
- );
-
onFloatingWindowChanged(bool toValue) async {
if (toValue) {
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {
@@ -688,18 +665,8 @@ class _SettingsState extends State with WidgetsBindingObserver {
SettingsTile(
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
? translate('Login')
- : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')),
- leading: Obx(() {
- final avatar = bind.mainResolveAvatarUrl(
- avatar: gFFI.userModel.avatar.value);
- return buildAvatarWidget(
- avatar: avatar,
- size: 28,
- borderRadius: null,
- fallback: Icon(Icons.person),
- ) ??
- Icon(Icons.person);
- }),
+ : '${translate('Logout')} (${gFFI.userModel.userName.value})')),
+ leading: Icon(Icons.person),
onPressed: (context) {
if (gFFI.userModel.userName.value.isEmpty) {
loginDialog();
@@ -819,37 +786,19 @@ class _SettingsState extends State with WidgetsBindingObserver {
showThemeSettings(gFFI.dialogManager);
},
),
- if (!bind.isDisableAccount())
- SettingsTile.switchTile(
- title: Text(translate('note-at-conn-end-tip')),
- initialValue: _allowAskForNoteAtEndOfConnection,
- onToggle: (v) async {
- if (v && !gFFI.userModel.isLogin) {
- final res = await loginDialog();
- if (res != true) return;
- }
- await mainSetLocalBoolOption(
- kOptionAllowAskForNoteAtEndOfConnection, v);
- final newValue = mainGetLocalBoolOptionSync(
- kOptionAllowAskForNoteAtEndOfConnection);
- setState(() {
- _allowAskForNoteAtEndOfConnection = newValue;
- });
- },
- ),
- if (!incomingOnly)
- SettingsTile.switchTile(
- title:
- Text(translate('keep-awake-during-outgoing-sessions-label')),
- initialValue: _preventSleepWhileConnected,
- onToggle: (v) async {
- await mainSetLocalBoolOption(
- kOptionKeepAwakeDuringOutgoingSessions, v);
- setState(() {
- _preventSleepWhileConnected = v;
- });
- },
- ),
+ SettingsTile.switchTile(
+ title: Text(translate('note-at-conn-end-tip')),
+ initialValue: _allowAskForNoteAtEndOfConnection,
+ onToggle: (v) async {
+ await mainSetLocalBoolOption(
+ kOptionAllowAskForNoteAtEndOfConnection, v);
+ final newValue = mainGetLocalBoolOptionSync(
+ kOptionAllowAskForNoteAtEndOfConnection);
+ setState(() {
+ _allowAskForNoteAtEndOfConnection = newValue;
+ });
+ },
+ )
]),
if (isAndroid)
SettingsSection(title: Text(translate('Hardware Codec')), tiles: [
diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart
index aff85b40c..17d9bbedb 100644
--- a/flutter/lib/mobile/pages/terminal_page.dart
+++ b/flutter/lib/mobile/pages/terminal_page.dart
@@ -1,6 +1,3 @@
-import 'dart:async';
-import 'dart:math';
-import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
@@ -10,7 +7,6 @@ import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
-import '../../consts.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
@@ -33,18 +29,9 @@ class TerminalPage extends StatefulWidget {
}
class _TerminalPageState extends State
- with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
+ with AutomaticKeepAliveClientMixin {
late FFI _ffi;
late TerminalModel _terminalModel;
- double? _cellHeight;
- double _sysKeyboardHeight = 0;
- Timer? _keyboardDebounce;
- final GlobalKey _keyboardKey = GlobalKey();
- double _keyboardHeight = 0;
- late bool _showTerminalExtraKeys;
- // For iOS edge swipe gesture
- double _swipeStartX = 0;
- double _swipeCurrentX = 0;
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
@@ -57,7 +44,6 @@ class _TerminalPageState extends State
@override
void initState() {
super.initState();
- WidgetsBinding.instance.addObserver(this);
debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
@@ -76,25 +62,13 @@ class _TerminalPageState extends State
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
- _terminalModel.onResizeExternal = (w, h, pw, ph) {
- _cellHeight = ph * 1.0;
- };
-
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
- // Web desktop users have full hardware keyboard access, so the on-screen
- // terminal extra keys bar is unnecessary and disabled.
- _showTerminalExtraKeys = !isWebDesktop &&
- mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
-
- if (_showTerminalExtraKeys) {
- _updateKeyboardHeight();
- }
});
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@@ -104,43 +78,10 @@ class _TerminalPageState extends State
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
- _keyboardDebounce?.cancel();
- WidgetsBinding.instance.removeObserver(this);
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
}
- @override
- void didChangeMetrics() {
- super.didChangeMetrics();
-
- _keyboardDebounce?.cancel();
- _keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
- final bottomInset = MediaQuery.of(context).viewInsets.bottom;
- setState(() {
- _sysKeyboardHeight = bottomInset;
- });
- });
- }
-
- void _updateKeyboardHeight() {
- if (_keyboardKey.currentContext != null) {
- final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
- _keyboardHeight = renderBox.size.height;
- }
- }
-
- EdgeInsets _calculatePadding(double heightPx) {
- if (_cellHeight == null) {
- return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
- }
- final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
- final rows = (realHeight / _cellHeight!).floor();
- final extraSpace = realHeight - rows * _cellHeight!;
- final topBottom = max(0.0, extraSpace / 2.0);
- return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
- }
-
@override
Widget build(BuildContext context) {
super.build(context);
@@ -154,277 +95,33 @@ class _TerminalPageState extends State
}
Widget buildBody() {
- final scaffold = Scaffold(
- resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
+ return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
- body: Stack(
- children: [
- Positioned.fill(
- child: SafeArea(
- top: true,
- child: LayoutBuilder(
- builder: (context, constraints) {
- final heightPx = constraints.maxHeight;
- return TerminalView(
- _terminalModel.terminal,
- controller: _terminalModel.terminalController,
- autofocus: true,
- textStyle: _getTerminalStyle(),
- backgroundOpacity: 0.7,
- // The following comment is from xterm.dart source code:
- // Workaround to detect delete key for platforms and IMEs that do not
- // emit a hardware delete event. Preferred on mobile platforms. [false] by
- // default.
- //
- // Android works fine without this workaround.
- deleteDetection: isIOS,
- padding: _calculatePadding(heightPx),
- onSecondaryTapDown: (details, offset) async {
- final selection = _terminalModel.terminalController.selection;
- if (selection != null) {
- final text = _terminalModel.terminal.buffer.getText(selection);
- _terminalModel.terminalController.clearSelection();
- await Clipboard.setData(ClipboardData(text: text));
- } else {
- final data = await Clipboard.getData('text/plain');
- final text = data?.text;
- if (text != null) {
- _terminalModel.terminal.paste(text);
- }
- }
- },
- );
- },
- ),
- ),
- ),
- if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
- // iOS-style circular close button in top-right corner
- if (isIOS) _buildCloseButton(),
- ],
- ),
- );
-
- // Add iOS edge swipe gesture to exit (similar to Android back button)
- if (isIOS) {
- return LayoutBuilder(
- builder: (context, constraints) {
- final screenWidth = constraints.maxWidth;
- // Base thresholds on screen width but clamp to reasonable logical pixel ranges
- // Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
- final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
- // Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
- final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
-
- return RawGestureDetector(
- behavior: HitTestBehavior.translucent,
- gestures: {
- HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers(
- () => HorizontalDragGestureRecognizer(
- debugOwner: this,
- // Only respond to touch input, exclude mouse/trackpad
- supportedDevices: kTouchBasedDeviceKinds,
- ),
- (HorizontalDragGestureRecognizer instance) {
- instance
- // Capture initial touch-down position (before touch slop)
- ..onDown = (details) {
- _swipeStartX = details.localPosition.dx;
- _swipeCurrentX = details.localPosition.dx;
- }
- ..onUpdate = (details) {
- _swipeCurrentX = details.localPosition.dx;
- }
- ..onEnd = (details) {
- // Check if swipe started from left edge and moved right
- if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
- clientClose(sessionId, _ffi);
- }
- _swipeStartX = 0;
- _swipeCurrentX = 0;
- }
- ..onCancel = () {
- _swipeStartX = 0;
- _swipeCurrentX = 0;
- };
- },
- ),
- },
- child: scaffold,
- );
+ body: TerminalView(
+ _terminalModel.terminal,
+ controller: _terminalModel.terminalController,
+ autofocus: true,
+ textStyle: _getTerminalStyle(),
+ backgroundOpacity: 0.7,
+ padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
+ onSecondaryTapDown: (details, offset) async {
+ final selection = _terminalModel.terminalController.selection;
+ if (selection != null) {
+ final text = _terminalModel.terminal.buffer.getText(selection);
+ _terminalModel.terminalController.clearSelection();
+ await Clipboard.setData(ClipboardData(text: text));
+ } else {
+ final data = await Clipboard.getData('text/plain');
+ final text = data?.text;
+ if (text != null) {
+ _terminalModel.terminal.paste(text);
+ }
+ }
},
- );
- }
-
- return scaffold;
- }
-
- Widget _buildCloseButton() {
- return Positioned(
- top: 0,
- right: 0,
- child: SafeArea(
- minimum: const EdgeInsets.only(
- top: 16, // iOS standard margin
- right: 16, // iOS standard margin
- ),
- child: Semantics(
- button: true,
- label: translate('Close'),
- child: Container(
- width: 44, // iOS standard tap target size
- height: 44,
- decoration: BoxDecoration(
- color: Colors.black.withOpacity(0.5), // Half transparency
- shape: BoxShape.circle,
- ),
- child: Material(
- color: Colors.transparent,
- shape: const CircleBorder(),
- clipBehavior: Clip.antiAlias,
- child: InkWell(
- customBorder: const CircleBorder(),
- onTap: () {
- clientClose(sessionId, _ffi);
- },
- child: Tooltip(
- message: translate('Close'),
- child: const Icon(
- Icons.chevron_left, // iOS-style back arrow
- color: Colors.white,
- size: 28,
- ),
- ),
- ),
- ),
- ),
- ),
),
);
}
- Widget _buildFloatingKeyboard() {
- return AnimatedPositioned(
- duration: const Duration(milliseconds: 200),
- left: 0,
- right: 0,
- bottom: _sysKeyboardHeight,
- child: Container(
- key: _keyboardKey,
- color: Theme.of(context).scaffoldBackgroundColor,
- padding: EdgeInsets.zero,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- _buildKeyButton('Esc'),
- const SizedBox(width: 2),
- _buildKeyButton('/'),
- const SizedBox(width: 2),
- _buildKeyButton('|'),
- const SizedBox(width: 2),
- _buildKeyButton('Home'),
- const SizedBox(width: 2),
- _buildKeyButton('↑'),
- const SizedBox(width: 2),
- _buildKeyButton('End'),
- const SizedBox(width: 2),
- _buildKeyButton('PgUp'),
- ],
- ),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- _buildKeyButton('Tab'),
- const SizedBox(width: 2),
- _buildKeyButton('Ctrl+C'),
- const SizedBox(width: 2),
- _buildKeyButton('~'),
- const SizedBox(width: 2),
- _buildKeyButton('←'),
- const SizedBox(width: 2),
- _buildKeyButton('↓'),
- const SizedBox(width: 2),
- _buildKeyButton('→'),
- const SizedBox(width: 2),
- _buildKeyButton('PgDn'),
- ],
- ),
- ],
- ),
- ),
- );
- }
-
- Widget _buildKeyButton(String label) {
- return ElevatedButton(
- onPressed: () {
- _sendKeyToTerminal(label);
- },
- child: Text(label),
- style: ElevatedButton.styleFrom(
- minimumSize: const Size(48, 32),
- padding: EdgeInsets.zero,
- textStyle: const TextStyle(fontSize: 12),
- backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
- foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
- ),
- );
- }
-
- void _sendKeyToTerminal(String key) {
- String? send;
-
- switch (key) {
- case 'Esc':
- send = '\x1B';
- break;
- case 'Tab':
- send = '\t';
- break;
- case 'Ctrl+C':
- send = '\x03';
- break;
-
- case '↑':
- send = '\x1B[A';
- break;
- case '↓':
- send = '\x1B[B';
- break;
- case '→':
- send = '\x1B[C';
- break;
- case '←':
- send = '\x1B[D';
- break;
-
- case 'Home':
- send = '\x1B[H';
- break;
- case 'End':
- send = '\x1B[F';
- break;
- case 'PgUp':
- send = '\x1B[5~';
- break;
- case 'PgDn':
- send = '\x1B[6~';
- break;
-
- default:
- send = key;
- break;
- }
-
- if (send != null) {
- _terminalModel.sendVirtualKey(send);
- }
- }
-
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
// https://github.com/TerminalStudio/xterm.dart/issues/198#issuecomment-2526548458
TerminalStyle _getTerminalStyle() {
diff --git a/flutter/lib/mobile/pages/view_camera_page.dart b/flutter/lib/mobile/pages/view_camera_page.dart
index 08c8cda1a..018d22980 100644
--- a/flutter/lib/mobile/pages/view_camera_page.dart
+++ b/flutter/lib/mobile/pages/view_camera_page.dart
@@ -11,6 +11,7 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
import '../../common.dart';
import '../../common/widgets/overlay.dart';
@@ -61,7 +62,7 @@ class _ViewCameraPageState extends State
bool _showGestureHelp = false;
Orientation? _currentOrientation;
double _viewInsetsBottom = 0;
- final _uniqueKey = UniqueKey();
+
Timer? _timerDidChangeMetrics;
final _blockableOverlayState = BlockableOverlayState();
@@ -99,7 +100,9 @@ class _ViewCameraPageState extends State
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
- WakelockManager.enable(_uniqueKey);
+ if (!isWeb) {
+ WakelockPlus.enable();
+ }
_physicalFocusNode.requestFocus();
gFFI.inputModel.listenToMouse(true);
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
@@ -136,7 +139,9 @@ class _ViewCameraPageState extends State
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
- WakelockManager.disable(_uniqueKey);
+ if (!isWeb) {
+ await WakelockPlus.disable();
+ }
removeSharedStates(widget.id);
// `on_voice_call_closed` should be called when the connection is ended.
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
@@ -259,11 +264,13 @@ class _ViewCameraPageState extends State
}
return Container(
color: MyTheme.canvasColor,
- child: RawTouchGestureDetectorRegion(
- child: getBodyForMobile(),
- ffi: gFFI,
- isCamera: true,
- ),
+ child: inputModel.isPhysicalMouse.value
+ ? getBodyForMobile()
+ : RawTouchGestureDetectorRegion(
+ child: getBodyForMobile(),
+ ffi: gFFI,
+ isCamera: true,
+ ),
);
}),
),
diff --git a/flutter/lib/mobile/widgets/dialog.dart b/flutter/lib/mobile/widgets/dialog.dart
index 8b645bb88..f6900e5dd 100644
--- a/flutter/lib/mobile/widgets/dialog.dart
+++ b/flutter/lib/mobile/widgets/dialog.dart
@@ -12,6 +12,100 @@ void _showSuccess() {
showToast(translate("Successful"));
}
+void _showError() {
+ showToast(translate("Error"));
+}
+
+void setPermanentPasswordDialog(OverlayDialogManager dialogManager) async {
+ final pw = await bind.mainGetPermanentPassword();
+ final p0 = TextEditingController(text: pw);
+ final p1 = TextEditingController(text: pw);
+ var validateLength = false;
+ var validateSame = false;
+ dialogManager.show((setState, close, context) {
+ submit() async {
+ close();
+ dialogManager.showLoading(translate("Waiting"));
+ if (await gFFI.serverModel.setPermanentPassword(p0.text)) {
+ dialogManager.dismissAll();
+ _showSuccess();
+ } else {
+ dialogManager.dismissAll();
+ _showError();
+ }
+ }
+
+ return CustomAlertDialog(
+ title: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.password_rounded, color: MyTheme.accent),
+ Text(translate('Set your own password')).paddingOnly(left: 10),
+ ],
+ ),
+ content: Form(
+ autovalidateMode: AutovalidateMode.onUserInteraction,
+ child: Column(mainAxisSize: MainAxisSize.min, children: [
+ TextFormField(
+ autofocus: true,
+ obscureText: true,
+ keyboardType: TextInputType.visiblePassword,
+ decoration: InputDecoration(
+ labelText: translate('Password'),
+ ),
+ controller: p0,
+ validator: (v) {
+ if (v == null) return null;
+ final val = v.trim().length > 5;
+ if (validateLength != val) {
+ // use delay to make setState success
+ Future.delayed(Duration(microseconds: 1),
+ () => setState(() => validateLength = val));
+ }
+ return val
+ ? null
+ : translate('Too short, at least 6 characters.');
+ },
+ ).workaroundFreezeLinuxMint(),
+ TextFormField(
+ obscureText: true,
+ keyboardType: TextInputType.visiblePassword,
+ decoration: InputDecoration(
+ labelText: translate('Confirmation'),
+ ),
+ controller: p1,
+ validator: (v) {
+ if (v == null) return null;
+ final val = p0.text == v;
+ if (validateSame != val) {
+ Future.delayed(Duration(microseconds: 1),
+ () => setState(() => validateSame = val));
+ }
+ return val
+ ? null
+ : translate('The confirmation is not identical.');
+ },
+ ).workaroundFreezeLinuxMint(),
+ ])),
+ onCancel: close,
+ onSubmit: (validateLength && validateSame) ? submit : null,
+ actions: [
+ dialogButton(
+ 'Cancel',
+ icon: Icon(Icons.close_rounded),
+ onPressed: close,
+ isOutline: true,
+ ),
+ dialogButton(
+ 'OK',
+ icon: Icon(Icons.done_rounded),
+ onPressed: (validateLength && validateSame) ? submit : null,
+ ),
+ ],
+ );
+ });
+}
+
void setTemporaryPasswordLengthDialog(
OverlayDialogManager dialogManager) async {
List lengths = ['6', '8', '10'];
diff --git a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart
index dbcc606af..ddb20860c 100644
--- a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart
+++ b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart
@@ -83,10 +83,7 @@ class _FloatingMouseWidgetsState extends State {
cursorModel: _cursorModel,
),
if (virtualMouseMode.showVirtualJoystick)
- VirtualJoystick(
- cursorModel: _cursorModel,
- inputModel: _inputModel,
- ),
+ VirtualJoystick(cursorModel: _cursorModel),
FloatingLeftRightButton(
isLeft: true,
inputModel: _inputModel,
@@ -677,18 +674,12 @@ class _QuarterCirclePainter extends CustomPainter {
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
-// Virtual joystick can send either absolute movement (via updatePan)
-// or relative movement (via sendMobileRelativeMouseMove) depending on the
-// InputModel.relativeMouseMode setting.
+// Virtual joystick sends the absolute movement for now.
+// Maybe we need to change it to relative movement in the future.
class VirtualJoystick extends StatefulWidget {
final CursorModel cursorModel;
- final InputModel inputModel;
- const VirtualJoystick({
- super.key,
- required this.cursorModel,
- required this.inputModel,
- });
+ const VirtualJoystick({super.key, required this.cursorModel});
@override
State createState() => _VirtualJoystickState();
@@ -703,10 +694,6 @@ class _VirtualJoystickState extends State {
final double _moveStep = 3.0;
final double _speed = 1.0;
- /// Scale factor for relative mouse movement sensitivity.
- /// Higher values result in faster cursor movement on the remote machine.
- static const double _kRelativeMouseScale = 3.0;
-
// One-shot timer to detect a drag gesture
Timer? _dragStartTimer;
// Periodic timer for continuous movement
@@ -714,9 +701,6 @@ class _VirtualJoystickState extends State {
Size? _lastScreenSize;
bool _isPressed = false;
- /// Check if relative mouse mode is enabled.
- bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
-
@override
void initState() {
super.initState();
@@ -762,18 +746,6 @@ class _VirtualJoystickState extends State {
);
}
- /// Send movement delta to remote machine.
- /// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
- void _sendMovement(Offset delta) {
- if (_useRelativeMouse) {
- widget.inputModel.sendMobileRelativeMouseMove(
- delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
- } else {
- // In absolute mode, use cursorModel.updatePan which tracks position.
- widget.cursorModel.updatePan(delta, Offset.zero, false);
- }
- }
-
void _stopSendEventTimer() {
_dragStartTimer?.cancel();
_continuousMoveTimer?.cancel();
@@ -801,7 +773,7 @@ class _VirtualJoystickState extends State {
// The movement is small for a gentle start.
final initialDelta = _offsetToPanDelta(_offset);
if (initialDelta.distance > 0) {
- _sendMovement(initialDelta);
+ widget.cursorModel.updatePan(initialDelta, Offset.zero, false);
}
// 2. Start a one-shot timer to check if the user is holding for a drag.
@@ -812,7 +784,10 @@ class _VirtualJoystickState extends State {
_continuousMoveTimer =
periodic_immediate(const Duration(milliseconds: 20), () async {
if (_offset != Offset.zero) {
- _sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
+ widget.cursorModel.updatePan(
+ _offsetToPanDelta(_offset) * _moveStep * _speed,
+ Offset.zero,
+ false);
}
});
});
diff --git a/flutter/lib/mobile/widgets/gesture_help.dart b/flutter/lib/mobile/widgets/gesture_help.dart
index 8e86681b4..30150be5a 100644
--- a/flutter/lib/mobile/widgets/gesture_help.dart
+++ b/flutter/lib/mobile/widgets/gesture_help.dart
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
-import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
-import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
class GestureIcons {
@@ -41,13 +39,11 @@ class GestureHelp extends StatefulWidget {
{Key? key,
required this.touchMode,
required this.onTouchModeChange,
- required this.virtualMouseMode,
- this.inputModel})
+ required this.virtualMouseMode})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
final VirtualMouseMode virtualMouseMode;
- final InputModel? inputModel;
@override
State createState() =>
@@ -65,14 +61,6 @@ class _GestureHelpState extends State {
_selectedIndex = _touchMode ? 1 : 0;
}
- /// Helper to exit relative mouse mode when certain conditions are met.
- /// This reduces code duplication across multiple UI callbacks.
- void _exitRelativeMouseModeIf(bool condition) {
- if (condition) {
- widget.inputModel?.setRelativeMouseMode(false);
- }
- }
-
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
@@ -115,8 +103,6 @@ class _GestureHelpState extends State {
_selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode);
- // Exit relative mouse mode when switching to touch mode
- _exitRelativeMouseModeIf(_touchMode);
}
});
},
@@ -131,18 +117,12 @@ class _GestureHelpState extends State {
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode.toggleVirtualMouse();
- // Exit relative mouse mode when virtual mouse is hidden
- _exitRelativeMouseModeIf(
- !_virtualMouseMode.showVirtualMouse);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode.toggleVirtualMouse();
- // Exit relative mouse mode when virtual mouse is hidden
- _exitRelativeMouseModeIf(
- !_virtualMouseMode.showVirtualMouse);
setState(() {});
},
child: Text(translate('Show virtual mouse')),
@@ -216,10 +196,6 @@ class _GestureHelpState extends State {
if (value == null) return;
await _virtualMouseMode
.toggleVirtualJoystick();
- // Exit relative mouse mode when joystick is hidden
- _exitRelativeMouseModeIf(
- !_virtualMouseMode
- .showVirtualJoystick);
setState(() {});
},
),
@@ -227,10 +203,6 @@ class _GestureHelpState extends State {
onTap: () async {
await _virtualMouseMode
.toggleVirtualJoystick();
- // Exit relative mouse mode when joystick is hidden
- _exitRelativeMouseModeIf(
- !_virtualMouseMode
- .showVirtualJoystick);
setState(() {});
},
child: Text(
@@ -239,39 +211,6 @@ class _GestureHelpState extends State {
],
)),
),
- // Relative mouse mode option - only visible when joystick is shown
- if (!_touchMode &&
- _virtualMouseMode.showVirtualMouse &&
- _virtualMouseMode.showVirtualJoystick &&
- widget.inputModel != null)
- Obx(() => Transform.translate(
- offset: const Offset(-10.0, -24.0),
- child: Padding(
- // Indent further for 'Relative mouse mode'
- padding: const EdgeInsets.only(left: 48.0),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Checkbox(
- value: widget.inputModel!
- .relativeMouseMode.value,
- onChanged: (value) {
- if (value == null) return;
- widget.inputModel!
- .setRelativeMouseMode(value);
- },
- ),
- InkWell(
- onTap: () {
- widget.inputModel!
- .toggleRelativeMouseMode();
- },
- child: Text(
- translate('Relative mouse mode')),
- ),
- ],
- )),
- )),
],
),
),
diff --git a/flutter/lib/models/ab_model.dart b/flutter/lib/models/ab_model.dart
index 001887c0c..1a165ce11 100644
--- a/flutter/lib/models/ab_model.dart
+++ b/flutter/lib/models/ab_model.dart
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
+import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
@@ -52,9 +53,7 @@ class AbModel {
RxBool get currentAbLoading => current.abLoading;
bool get currentAbEmpty => current.peers.isEmpty && current.tags.isEmpty;
- final _listPullError = ''.obs;
- RxString get abPullError =>
- _listPullError.value.isNotEmpty ? _listPullError : current.pullError;
+ RxString get currentAbPullError => current.pullError;
RxString get currentAbPushError => current.pushError;
String? _personalAbGuid;
RxBool legacyMode = false.obs;
@@ -69,7 +68,6 @@ class AbModel {
var _syncFromRecentLock = false;
var _timerCounter = 0;
var _cacheLoadOnceFlag = false;
- var _pulledOnce = false;
var listInitialized = false;
var _maxPeerOneAb = 0;
@@ -99,17 +97,10 @@ class AbModel {
print("reset ab model");
addressbooks.clear();
_currentName.value = '';
- _listPullError.value = '';
- _pulledOnce = false;
await bind.mainClearAb();
listInitialized = false;
}
- void clearPullErrors() {
- _listPullError.value = '';
- current.pullError.value = '';
- }
-
// #region ab
/// Pulls the address book data from the server.
///
@@ -119,41 +110,31 @@ class AbModel {
var _pulling = false;
Future pullAb(
{required ForcePullAb? force, required bool quiet}) async {
- if (bind.isDisableAb()) return;
- if (!gFFI.userModel.isLogin) return;
- if (gFFI.userModel.networkError.isNotEmpty) return;
if (_pulling) return;
- if (force == null && _pulledOnce) {
- return;
- }
_pulling = true;
- if (!quiet) {
- _listPullError.value = '';
- current.pullError.value = '';
- }
try {
await _pullAb(force: force, quiet: quiet);
_refreshTab();
} catch (_) {}
_pulling = false;
- _pulledOnce = true;
}
Future _pullAb(
{required ForcePullAb? force, required bool quiet}) async {
+ if (bind.isDisableAb()) return;
+ 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
_personalAbGuid = null;
- // `true`: continue init. `false`: stop, error already recorded.
- if (!await _getPersonalAbGuid(quiet: quiet)) {
- return;
- }
+ await _getPersonalAbGuid();
+ // Determine legacy mode based on whether _personalAbGuid is null
legacyMode.value = _personalAbGuid == null;
if (!legacyMode.value && _maxPeerOneAb == 0) {
- await _getAbSettings(quiet: quiet);
+ await _getAbSettings();
}
if (_personalAbGuid != null) {
debugPrint("pull ab list");
@@ -161,7 +142,7 @@ class AbModel {
abProfiles.add(AbProfile(_personalAbGuid!, _personalAddressBookName,
gFFI.userModel.userName.value, null, ShareRule.read.value, null));
// get all address book name
- await _getSharedAbProfiles(abProfiles, quiet: quiet);
+ await _getSharedAbProfiles(abProfiles);
addressbooks.removeWhere((key, value) =>
abProfiles.firstWhereOrNull((e) => e.name == key) == null);
for (int i = 0; i < abProfiles.length; i++) {
@@ -201,7 +182,6 @@ class AbModel {
}
} catch (e) {
debugPrint("pull ab list error: $e");
- _setListPullError(e, quiet: quiet);
}
} else if (listInitialized &&
(!current.initialized || force == ForcePullAb.current)) {
@@ -217,26 +197,13 @@ class AbModel {
}
}
- void _setListPullError(Object err, {required bool quiet, int? statusCode}) {
- if (!quiet) {
- _listPullError.value =
- '${translate('pull_ab_failed_tip')}: ${translate(err.toString())}';
- }
- if (statusCode == 401) {
- gFFI.userModel.reset(resetOther: true);
- }
- }
-
- Future _getAbSettings({required bool quiet}) async {
- int? statusCode;
+ Future _getAbSettings() async {
try {
final api = "${await bind.mainGetApiServer()}/api/ab/settings";
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
- _setEmptyBody(headers);
final resp = await http.post(Uri.parse(api), headers: headers);
- statusCode = resp.statusCode;
- if (statusCode == 404) {
+ if (resp.statusCode == 404) {
debugPrint("HTTP 404, api server doesn't support shared address book");
return false;
}
@@ -245,57 +212,45 @@ class AbModel {
if (json.containsKey('error')) {
throw json['error'];
}
- if (statusCode != 200) {
- throw 'HTTP $statusCode';
+ if (resp.statusCode != 200) {
+ throw 'HTTP ${resp.statusCode}';
}
_maxPeerOneAb = json['max_peer_one_ab'] ?? 0;
return true;
} catch (err) {
debugPrint('get ab settings err: ${err.toString()}');
- _setListPullError(err, quiet: quiet, statusCode: statusCode);
}
return false;
}
- /// Loads `/api/ab/personal`.
- /// Returns `true` to continue init, `false` to stop after a real error.
- Future _getPersonalAbGuid({required bool quiet}) async {
- int? statusCode;
+ Future _getPersonalAbGuid() async {
try {
final api = "${await bind.mainGetApiServer()}/api/ab/personal";
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
- _setEmptyBody(headers);
final resp = await http.post(Uri.parse(api), headers: headers);
- statusCode = resp.statusCode;
- if (statusCode == 404) {
+ if (resp.statusCode == 404) {
debugPrint("HTTP 404, current api server is legacy mode");
- // Old server: keep `_personalAbGuid` null and continue in legacy mode.
- return true;
+ return false;
}
Map json =
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
if (json.containsKey('error')) {
throw json['error'];
}
- if (statusCode != 200) {
- throw 'HTTP $statusCode';
+ if (resp.statusCode != 200) {
+ throw 'HTTP ${resp.statusCode}';
}
_personalAbGuid = json['guid'];
- // New server: guid is available, continue in non-legacy mode.
return true;
} catch (err) {
debugPrint('get personal ab err: ${err.toString()}');
- _setListPullError(err, quiet: quiet, statusCode: statusCode);
}
- // Real error: stop the current pull.
return false;
}
- Future _getSharedAbProfiles(List profiles,
- {required bool quiet}) async {
+ Future _getSharedAbProfiles(List profiles) async {
final api = "${await bind.mainGetApiServer()}/api/ab/shared/profiles";
- int? statusCode;
try {
var uri0 = Uri.parse(api);
final pageSize = 100;
@@ -314,21 +269,14 @@ class AbModel {
});
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
- _setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
- statusCode = resp.statusCode;
- if (statusCode == 404) {
- debugPrint(
- "HTTP 404, api server doesn't support shared address book");
- return false;
- }
Map json =
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
if (json.containsKey('error')) {
throw json['error'];
}
- if (statusCode != 200) {
- throw 'HTTP $statusCode';
+ if (resp.statusCode != 200) {
+ throw 'HTTP ${resp.statusCode}';
}
if (json.containsKey('total')) {
if (total == 0) total = json['total'];
@@ -351,7 +299,6 @@ class AbModel {
return true;
} catch (err) {
debugPrint('_getSharedAbProfiles err: ${err.toString()}');
- _setListPullError(err, quiet: quiet, statusCode: statusCode);
}
return false;
}
@@ -1065,8 +1012,16 @@ class LegacyAb extends BaseAb {
var authHeaders = getHttpHeaders();
authHeaders['Content-Type'] = "application/json";
final body = jsonEncode({"data": jsonEncode(_serialize())});
- http.Response resp =
- await http.post(Uri.parse(api), headers: authHeaders, body: body);
+ http.Response resp;
+ // support compression
+ if (licensedDevices > 0 && body.length > 1024) {
+ authHeaders['Content-Encoding'] = "gzip";
+ resp = await http.post(Uri.parse(api),
+ headers: authHeaders, body: GZipCodec().encode(utf8.encode(body)));
+ } else {
+ resp =
+ await http.post(Uri.parse(api), headers: authHeaders, body: body);
+ }
if (resp.statusCode == 200 &&
(resp.body.isEmpty || resp.body.toLowerCase() == 'null')) {
ret = true;
@@ -1451,7 +1406,6 @@ class Ab extends BaseAb {
});
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
- _setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
statusCode = resp.statusCode;
Map json =
@@ -1509,7 +1463,6 @@ class Ab extends BaseAb {
);
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
- _setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
statusCode = resp.statusCode;
List json =
@@ -2024,8 +1977,3 @@ String _jsonDecodeActionResp(http.Response resp) {
}
return errMsg;
}
-
-// https://github.com/seanmonstar/reqwest/issues/838
-void _setEmptyBody(Map headers) {
- headers['Content-Length'] = '0';
-}
diff --git a/flutter/lib/models/cm_file_model.dart b/flutter/lib/models/cm_file_model.dart
index 46935c188..6609f1191 100644
--- a/flutter/lib/models/cm_file_model.dart
+++ b/flutter/lib/models/cm_file_model.dart
@@ -275,7 +275,7 @@ class TransferJobSerdeData {
: this(
connId: d['connId'] ?? 0,
id: int.tryParse(d['id'].toString()) ?? 0,
- path: d['dataSource'] ?? '',
+ path: d['path'] ?? '',
isRemote: d['isRemote'] ?? false,
totalSize: d['totalSize'] ?? 0,
finishedSize: d['finishedSize'] ?? 0,
diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart
index 7d91b03b3..d2ae7cff2 100644
--- a/flutter/lib/models/file_model.dart
+++ b/flutter/lib/models/file_model.dart
@@ -113,34 +113,6 @@ class FileModel {
fileFetcher.tryCompleteEmptyDirsTask(evt['value'], evt['is_local']);
}
- // This method fixes a deadlock that occurred when the previous code directly
- // called jobController.jobError(evt) in the job_error event handler.
- //
- // The problem with directly calling jobController.jobError():
- // 1. fetchDirectoryRecursiveToRemove(jobID) registers readRecursiveTasks[jobID]
- // and waits for completion
- // 2. If the remote has no permission (or some other errors), it returns a FileTransferError
- // 3. The error triggers job_error event, which called jobController.jobError()
- // 4. jobController.jobError() calls getJob(jobID) to find the job in jobTable
- // 5. But addDeleteDirJob() is called AFTER fetchDirectoryRecursiveToRemove(),
- // so the job doesn't exist yet in jobTable
- // 6. Result: jobController.jobError() does nothing useful, and
- // readRecursiveTasks[jobID] never completes, causing a 2s timeout
- //
- // Solution: Before calling jobController.jobError(), we first check if there's
- // a pending readRecursiveTasks with this ID and complete it with the error.
- void handleJobError(Map evt) {
- final id = int.tryParse(evt['id']?.toString() ?? '');
- if (id != null) {
- final err = evt['err']?.toString() ?? 'Unknown error';
- fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
- }
- // Always call jobController.jobError(evt) to ensure all error events are processed,
- // even if the event does not have a valid job ID. This allows for generic error handling
- // or logging of unexpected errors.
- jobController.jobError(evt);
- }
-
Future postOverrideFileConfirm(Map evt) async {
evtLoop.pushEvent(
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
@@ -391,30 +363,14 @@ class FileController {
await Future.delayed(Duration(milliseconds: 100));
- final savedDir = (await bind.sessionGetPeerOption(
+ final dir = (await bind.sessionGetPeerOption(
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
- Future tryOpenReadyDirs() async {
- final dirs = {
- if (directory.value.path.isNotEmpty) directory.value.path,
- if (savedDir.isNotEmpty) savedDir,
- options.value.home,
- };
- for (final dir in dirs) {
- if (await _openDirectoryPath(dir, isBack: true)) {
- return true;
- }
- }
- return false;
- }
-
- var opened = await tryOpenReadyDirs();
+ openDirectory(dir.isEmpty ? options.value.home : dir);
await Future.delayed(Duration(seconds: 1));
- if (!opened) {
- // The peer may become ready during the reconnect delay, so retry the
- // same candidates instead of only retrying the default home directory.
- await tryOpenReadyDirs();
+ if (directory.value.path.isEmpty) {
+ openDirectory(options.value.home);
}
}
@@ -445,23 +401,19 @@ class FileController {
});
}
- Future refresh() async {
- // "." can be both a refresh command and a real remote directory path.
- // Refresh must bypass openDirectory's command dispatch to avoid recursion.
- return await _openDirectoryPath(directory.value.path, isBack: true);
+ Future refresh() async {
+ await openDirectory(directory.value.path);
}
- Future openDirectory(String path, {bool isBack = false}) async {
- if (!isBack && path == ".") {
- return await refresh();
+ Future openDirectory(String path, {bool isBack = false}) async {
+ if (path == ".") {
+ refresh();
+ return;
}
- if (!isBack && path == "..") {
- return await _goToParentDirectory(isBack: isBack);
+ if (path == "..") {
+ goToParentDirectory();
+ return;
}
- return await _openDirectoryPath(path, isBack: isBack);
- }
-
- Future _openDirectoryPath(String path, {bool isBack = false}) async {
if (!isBack) {
pushHistory();
}
@@ -478,10 +430,8 @@ class FileController {
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
fd.format(isWindows, sort: sortBy.value);
directory.value = fd;
- return true;
} catch (e) {
debugPrint("Failed to openDirectory $path: $e");
- return false;
}
}
@@ -509,22 +459,19 @@ class FileController {
goBack();
return;
}
- unawaited(_openDirectoryPath(path, isBack: true).then((_) {}));
+ openDirectory(path, isBack: true);
}
void goToParentDirectory() {
- unawaited(_goToParentDirectory().then((_) {}));
- }
-
- Future _goToParentDirectory({bool isBack = false}) async {
final isWindows = options.value.isWindows;
final dirPath = directory.value.path;
var parent = PathUtil.dirname(dirPath, isWindows);
// specially for C:\, D:\, goto '/'
if (parent == dirPath && isWindows) {
- return await _openDirectoryPath('/', isBack: isBack);
+ openDirectory('/');
+ return;
}
- return await _openDirectoryPath(parent, isBack: isBack);
+ openDirectory(parent);
}
// TODO deprecated this
@@ -644,21 +591,8 @@ class FileController {
} else if (item.isDirectory) {
title = translate("Not an empty directory");
dialogManager?.showLoading(translate("Waiting"));
- final FileDirectory fd;
- try {
- fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
- jobID, item.path, items.isLocal, true);
- } catch (e) {
- dialogManager?.dismissAll();
- final dm = dialogManager;
- if (dm != null) {
- msgBox(sessionId, 'custom-error-nook-nocancel-hasclose',
- translate("Error"), e.toString(), '', dm);
- } else {
- debugPrint("removeAction error msgbox failed: $e");
- }
- return;
- }
+ final fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
+ jobID, item.path, items.isLocal, true);
if (fd.path.isEmpty) {
fd.path = item.path;
}
@@ -672,7 +606,7 @@ class FileController {
item.name,
false);
if (confirm == true) {
- await sendRemoveEmptyDir(
+ sendRemoveEmptyDir(
item.path,
0,
deleteJobId,
@@ -713,7 +647,7 @@ class FileController {
// handle remove res;
if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) {
- await sendRemoveEmptyDir(item.path, i, deleteJobId);
+ sendRemoveEmptyDir(item.path, i, deleteJobId);
}
} else {
jobController.updateJobStatus(deleteJobId,
@@ -726,7 +660,7 @@ class FileController {
final res = await jobController.jobResultListener.start();
if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) {
- await sendRemoveEmptyDir(item.path, i, deleteJobId);
+ sendRemoveEmptyDir(item.path, i, deleteJobId);
}
}
} else {
@@ -821,9 +755,9 @@ class FileController {
fileNum: fileNum);
}
- Future sendRemoveEmptyDir(String path, int fileNum, int actId) async {
+ void sendRemoveEmptyDir(String path, int fileNum, int actId) {
history.removeWhere((element) => element.contains(path));
- await bind.sessionRemoveAllEmptyDirs(
+ bind.sessionRemoveAllEmptyDirs(
sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
}
@@ -1341,15 +1275,6 @@ class FileFetcher {
}
}
- // Complete a pending recursive read task with an error.
- // See FileModel.handleJobError() for why this is necessary.
- void tryCompleteRecursiveTaskWithError(int id, String error) {
- final completer = readRecursiveTasks.remove(id);
- if (completer != null && !completer.isCompleted) {
- completer.completeError(error);
- }
- }
-
Future> readEmptyDirs(
String path, bool isLocal, bool showHidden) async {
try {
@@ -1513,10 +1438,6 @@ class JobProgress {
var err = "";
int lastTransferredSize = 0;
- double get percent =>
- totalSize > 0 ? (finishedSize.toDouble() / totalSize) : 0.0;
- String get percentText => '${(percent * 100).toStringAsFixed(0)}%';
-
clear() {
type = JobType.none;
state = JobState.none;
diff --git a/flutter/lib/models/group_model.dart b/flutter/lib/models/group_model.dart
index d55cff453..c6ba992d2 100644
--- a/flutter/lib/models/group_model.dart
+++ b/flutter/lib/models/group_model.dart
@@ -343,7 +343,6 @@ class GroupModel {
}
reset() async {
- initialized = false;
groupLoadError.value = '';
deviceGroups.clear();
users.clear();
diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart
index 984d6a25c..8a3e60779 100644
--- a/flutter/lib/models/input_model.dart
+++ b/flutter/lib/models/input_model.dart
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
-import 'package:flutter/foundation.dart';
import 'dart:ui' as ui;
import 'package:desktop_multi_window/desktop_multi_window.dart';
@@ -15,14 +14,11 @@ import 'package:get/get.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
-import '../../models/state_model.dart';
-import 'input_modifier_utils.dart';
-import 'relative_mouse_model.dart';
import '../common.dart';
import '../consts.dart';
/// Mouse button enum.
-enum MouseButtons { left, right, wheel, back, forward }
+enum MouseButtons { left, right, wheel, back }
const _kMouseEventDown = 'mousedown';
const _kMouseEventUp = 'mouseup';
@@ -61,8 +57,7 @@ class CanvasCoords {
model.scale = json['scale'];
model.scrollX = json['scrollX'];
model.scrollY = json['scrollY'];
- model.scrollStyle =
- ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
+ model.scrollStyle = ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
model.size = Size(json['size']['w'], json['size']['h']);
return model;
}
@@ -159,8 +154,6 @@ extension ToString on MouseButtons {
return 'wheel';
case MouseButtons.back:
return 'back';
- case MouseButtons.forward:
- return 'forward';
}
}
}
@@ -331,80 +324,6 @@ class ToReleaseKeys {
}
class InputModel {
- // Side mouse button support for Linux.
- // Flutter's Linux embedder drops X11 button 8/9 events, so we capture them
- // natively via GDK and forward through the platform channel.
- static InputModel? _activeSideButtonModel;
- // Tracks per-button which model received a side button down event, so the
- // matching up event is routed there even if the pointer has left the view
- // or a different button was pressed in between.
- static final Map _sideButtonDownModels = {};
- static bool _sideButtonChannelInitialized = false;
-
- /// Each Flutter engine (main window + sub-windows from desktop_multi_window)
- /// runs its own Dart isolate with its own statics. Called from initEnv()
- /// which runs per-engine, so each isolate registers its own handler tied
- /// to its own set of InputModels.
- static void initSideButtonChannel() {
- if (!isLinux) return;
- if (_sideButtonChannelInitialized) return;
- _sideButtonChannelInitialized = true;
-
- const channel = MethodChannel('org.rustdesk.rustdesk/side_buttons');
- channel.setMethodCallHandler((call) async {
- if (call.method == 'onSideMouseButton') {
- final args = call.arguments as Map;
- final button = args['button'] as String;
- final type = args['type'] as String;
- final mb = button == 'back' ? MouseButtons.back : MouseButtons.forward;
-
- if (type == 'down') {
- final model = _activeSideButtonModel;
- if (model != null &&
- !(model.isViewOnly && !model.showMyCursor) &&
- model.keyboardPerm &&
- !model.isViewCamera) {
- _sideButtonDownModels[mb] = model;
- // Fire-and-forget to avoid blocking the platform channel handler.
- unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
- debugPrint('[InputModel] failed to send side button $type for $mb: $e');
- }));
- }
- } else {
- // Only route 'up' when we recorded the matching 'down';
- // dropping avoids sending unpaired 'up' to an unrelated session.
- // Use _sendMouseUnchecked to bypass permission checks so the
- // release always goes through even if permissions changed.
- final model = _sideButtonDownModels.remove(mb);
- if (model != null) {
- unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
- debugPrint('[InputModel] failed to send side button $type for $mb: $e');
- }));
- }
- }
- }
- return null;
- });
- }
-
- /// Clear any static references to this model (prevents stale routing).
- /// Releases any held side buttons on the peer so closing a session
- /// mid-press does not leave a stuck button.
- void disposeSideButtonTracking() {
- if (_activeSideButtonModel == this) _activeSideButtonModel = null;
- final held = _sideButtonDownModels.entries
- .where((e) => e.value == this)
- .map((e) => e.key)
- .toList();
- for (final mb in held) {
- _sideButtonDownModels.remove(mb);
- // Best-effort release; session may already be tearing down.
- unawaited(_sendMouseUnchecked('up', mb).catchError((Object e) {
- debugPrint('[InputModel] failed to release side button $mb: $e');
- }));
- }
- }
-
final WeakReference parent;
String keyboardMode = '';
@@ -426,47 +345,18 @@ class InputModel {
final _trackpadAdjustPeerLinux = 0.06;
// This is an experience value.
final _trackpadAdjustMacToWin = 2.50;
- // Ignore directional locking for very small deltas on both axes (including
- // tiny single-axis movement) to avoid over-filtering near zero.
- static const double _trackpadAxisNoiseThreshold = 0.2;
- // Lock to dominant axis only when one axis is clearly stronger.
- // 1.6 means the dominant axis must be >= 60% larger than the other.
- static const double _trackpadAxisLockRatio = 1.6;
int _trackpadSpeed = kDefaultTrackpadSpeed;
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
var _trackpadScrollUnsent = Offset.zero;
- // Mobile relative mouse delta accumulators (for slow/fine movements).
- double _mobileDeltaRemainderX = 0.0;
- double _mobileDeltaRemainderY = 0.0;
-
var _lastScale = 1.0;
bool _pointerMovedAfterEnter = false;
- bool _pointerInsideImage = false;
// mouse
final isPhysicalMouse = false.obs;
int _lastButtons = 0;
Offset lastMousePos = Offset.zero;
- int _lastWheelTsUs = 0;
-
- // Wheel acceleration thresholds.
- static const int _wheelAccelFastThresholdUs = 40000; // 40ms
- static const int _wheelAccelMediumThresholdUs = 80000; // 80ms
- static const double _wheelBurstVelocityThreshold =
- 0.002; // delta units per microsecond
- // Wheel burst acceleration (empirical tuning).
- // Applies only to fast, non-smooth bursts to preserve single-step scrolling.
- // Flutter uses microseconds for dt, so velocity is in delta/us.
-
- // Relative mouse mode (for games/3D apps).
- final relativeMouseMode = false.obs;
- late final RelativeMouseModel _relativeMouse;
- // Callback to cancel external throttle timer when relative mouse mode is disabled.
- VoidCallback? onRelativeMouseModeDisabled;
- // Disposer for the relativeMouseMode observer (to prevent memory leaks).
- Worker? _relativeMouseModeDisposer;
bool _queryOtherWindowCoords = false;
Rect? _windowRect;
@@ -477,109 +367,15 @@ class InputModel {
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
String get id => parent.target?.id ?? '';
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
- String get peerVersion => parent.target?.ffiModel.pi.version ?? '';
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
int get trackpadSpeed => _trackpadSpeed;
- bool get useEdgeScroll =>
- parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge;
-
- /// Check if the connected server supports relative mouse mode.
- bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
+ bool get useEdgeScroll => parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge;
InputModel(this.parent) {
- initSideButtonChannel();
sessionId = parent.target!.sessionId;
- _relativeMouse = RelativeMouseModel(
- sessionId: sessionId,
- enabled: relativeMouseMode,
- keyboardPerm: () => keyboardPerm,
- isViewCamera: () => isViewCamera,
- peerVersion: () => peerVersion,
- peerPlatform: () => peerPlatform,
- modify: (msg) => modify(msg),
- getPointerInsideImage: () => _pointerInsideImage,
- setPointerInsideImage: (inside) => _pointerInsideImage = inside,
- );
- _relativeMouse.onDisabled = () => onRelativeMouseModeDisabled?.call();
-
- // Sync relative mouse mode state to global state for UI components (e.g., tab bar hint).
- _relativeMouseModeDisposer = ever(relativeMouseMode, (bool value) {
- final peerId = id;
- if (peerId.isNotEmpty) {
- stateGlobal.relativeMouseModeState[peerId] = value;
- }
- });
- }
-
- // https://github.com/flutter/flutter/issues/157241
- // Infer CapsLock state from the character output.
- // This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report
- // incorrect CapsLock state on iOS.
- bool _getIosCapsFromCharacter(KeyEvent e) {
- if (!isIOS) return false;
- final ch = e.character;
- return _getIosCapsFromCharacterImpl(
- ch, HardwareKeyboard.instance.isShiftPressed);
- }
-
- // RawKeyEvent version of _getIosCapsFromCharacter.
- bool _getIosCapsFromRawCharacter(RawKeyEvent e) {
- if (!isIOS) return false;
- final ch = e.character;
- return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed);
- }
-
- // Shared implementation for inferring CapsLock state from character.
- // Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É).
- //
- // Limitations:
- // 1. This inference assumes the client and server use the same keyboard layout.
- // If layouts differ (e.g., client uses EN, server uses DE), the character output
- // may not match expectations. For example, ';' on EN layout maps to 'ö' on DE
- // layout, making it impossible to correctly infer CapsLock state from the
- // character alone.
- // 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it
- // produces lowercase). This method cannot handle that case correctly.
- bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) {
- if (ch == null || ch.length != 1) return false;
- // Use Dart's built-in Unicode-aware case detection
- final upper = ch.toUpperCase();
- final lower = ch.toLowerCase();
- final isUpper = upper == ch && lower != ch;
- final isLower = lower == ch && upper != ch;
- // Skip non-letter characters (e.g., numbers, symbols, CJK characters without case)
- if (!isUpper && !isLower) return false;
- return isUpper != shiftPressed;
- }
-
- int _buildLockModes(bool iosCapsLock) {
- const capslock = 1;
- const numlock = 2;
- const scrolllock = 3;
- int lockModes = 0;
- if (isIOS) {
- if (iosCapsLock) {
- lockModes |= (1 << capslock);
- }
- // Ignore "NumLock/ScrollLock" on iOS for now.
- } else {
- 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);
- }
- }
- return lockModes;
}
// This function must be called after the peer info is received.
@@ -699,38 +495,6 @@ class InputModel {
}
}
- // Safe: this only re-dispatches synthesized Shift key-up events.
- // The key-up path clears the tracked Shift state so this does not loop.
- void _releaseTrackedShiftKeyEventIfNeeded() {
- final leftShift = toReleaseKeys.lastLShiftKeyEvent;
- final rightShift = toReleaseKeys.lastRShiftKeyEvent;
- if (leftShift != null) {
- handleKeyEvent(leftShift);
- }
- if (rightShift != null) {
- handleKeyEvent(rightShift);
- }
- }
-
- // Safe: this only re-dispatches synthesized Shift key-up events.
- // The raw key-up path clears the tracked Shift state so this does not loop.
- void _releaseTrackedRawShiftKeyEventIfNeeded() {
- final leftShift = toReleaseRawKeys.lastLShiftKeyEvent;
- final rightShift = toReleaseRawKeys.lastRShiftKeyEvent;
- if (leftShift != null) {
- handleRawKeyEvent(RawKeyUpEvent(
- data: leftShift.data,
- character: leftShift.character,
- ));
- }
- if (rightShift != null) {
- handleRawKeyEvent(RawKeyUpEvent(
- data: rightShift.data,
- character: rightShift.character,
- ));
- }
- }
-
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (isViewCamera) return KeyEventResult.handled;
@@ -742,15 +506,6 @@ class InputModel {
}
}
- if (_relativeMouse.handleRawKeyEvent(e)) {
- return KeyEventResult.handled;
- }
-
- bool iosCapsLock = false;
- if (isIOS && e is RawKeyDownEvent) {
- iosCapsLock = _getIosCapsFromRawCharacter(e);
- }
-
final key = e.logicalKey;
if (e is RawKeyDownEvent) {
if (!e.repeat) {
@@ -785,30 +540,9 @@ class InputModel {
toReleaseRawKeys.updateKeyUp(key, e);
}
- // On some mobile soft-keyboard paths, Flutter may leave cached Shift state
- // set even though the current raw key event is not shifted anymore.
- if (e is RawKeyDownEvent &&
- shouldReleaseStaleMobileShift(
- isMobile: isMobile,
- cachedShiftPressed: shift,
- actualShiftPressed: e.isShiftPressed,
- logicalKey: e.logicalKey,
- hasTrackedShiftKeyDown: toReleaseRawKeys.lastLShiftKeyEvent != null ||
- toReleaseRawKeys.lastRShiftKeyEvent != null,
- )) {
- if (kDebugMode) {
- debugPrint(
- 'input: releasing stale mobile Shift before replaying tracked raw '
- 'key-up (logicalKey=${e.logicalKey.keyLabel}, '
- 'actualShiftPressed=${e.isShiftPressed}, cachedShiftPressed=$shift)',
- );
- }
- _releaseTrackedRawShiftKeyEventIfNeeded();
- }
-
// * Currently mobile does not enable map mode
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
- mapKeyboardModeRaw(e, iosCapsLock);
+ mapKeyboardModeRaw(e);
} else {
legacyKeyboardModeRaw(e);
}
@@ -834,23 +568,6 @@ class InputModel {
}
}
- if (_relativeMouse.handleKeyEvent(
- e,
- ctrlPressed: ctrl,
- shiftPressed: shift,
- altPressed: alt,
- commandPressed: command,
- )) {
- return KeyEventResult.handled;
- }
-
- bool iosCapsLock = false;
- if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) {
- iosCapsLock = _getIosCapsFromCharacter(e);
- }
-
- // Update cached modifier state before sending the event. The stale mobile
- // Shift release check below relies on this cached state.
if (e is KeyUpEvent) {
handleKeyUpEventModifiers(e);
} else if (e is KeyDownEvent) {
@@ -888,21 +605,6 @@ class InputModel {
}
}
}
-
- // On some mobile soft-keyboard paths, Flutter may leave cached Shift state
- // set even though the current key event is not shifted anymore.
- if (e is KeyDownEvent &&
- shouldReleaseStaleMobileShift(
- isMobile: isMobile,
- cachedShiftPressed: shift,
- actualShiftPressed: HardwareKeyboard.instance.isShiftPressed,
- logicalKey: e.logicalKey,
- hasTrackedShiftKeyDown: toReleaseKeys.lastLShiftKeyEvent != null ||
- toReleaseKeys.lastRShiftKeyEvent != null,
- )) {
- _releaseTrackedShiftKeyEventIfNeeded();
- }
-
final isDesktopAndMapMode =
isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode);
if (isMobileAndMapMode || isDesktopAndMapMode) {
@@ -911,8 +613,7 @@ class InputModel {
e.character ?? '',
e.physicalKey.usbHidUsage & 0xFFFF,
// Show repeat event be converted to "release+press" events?
- e is KeyDownEvent || e is KeyRepeatEvent,
- iosCapsLock);
+ e is KeyDownEvent || e is KeyRepeatEvent);
} else {
legacyKeyboardMode(e);
}
@@ -921,9 +622,23 @@ class InputModel {
}
/// Send Key Event
- void newKeyboardMode(
- String character, int usbHid, bool down, bool iosCapsLock) {
- final lockModes = _buildLockModes(iosCapsLock);
+ 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,
@@ -932,7 +647,7 @@ class InputModel {
downOrUp: down);
}
- void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) {
+ void mapKeyboardModeRaw(RawKeyEvent e) {
int positionCode = -1;
int platformCode = -1;
bool down;
@@ -963,14 +678,27 @@ class InputModel {
} else {
down = false;
}
- inputRawKey(
- e.character ?? '', platformCode, positionCode, down, iosCapsLock);
+ inputRawKey(e.character ?? '', platformCode, positionCode, down);
}
/// Send raw Key Event
- void inputRawKey(String name, int platformCode, int positionCode, bool down,
- bool iosCapsLock) {
- final lockModes = _buildLockModes(iosCapsLock);
+ void inputRawKey(String name, int platformCode, int positionCode, 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.sessionHandleFlutterRawKeyEvent(
sessionId: sessionId,
name: name,
@@ -1044,9 +772,6 @@ class InputModel {
Map _getMouseEvent(PointerEvent evt, String type) {
final Map out = {};
- bool hasStaleButtonsOnMouseUp =
- type == _kMouseEventUp && evt.buttons == _lastButtons;
-
// Check update event type and set buttons to be sent.
int buttons = _lastButtons;
if (type == _kMouseEventMove) {
@@ -1071,7 +796,7 @@ class InputModel {
buttons = evt.buttons;
}
}
- _lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons;
+ _lastButtons = evt.buttons;
out['buttons'] = buttons;
out['type'] = type;
@@ -1085,6 +810,10 @@ class InputModel {
}
Future tapDown(MouseButtons button) async {
+ if (!_pointerMovedAfterEnter) {
+ refreshMousePos();
+ await Future.delayed(Duration(milliseconds: 10));
+ }
await sendMouse('down', button);
}
@@ -1115,41 +844,24 @@ class InputModel {
return evt;
}
- /// Send mouse event unconditionally (no permission checks).
- /// Used for side button releases that must go through even if permissions
- /// changed after the matching down was sent.
- Future _sendMouseUnchecked(String type, MouseButtons button) async {
- await bind.sessionSendMouse(
- sessionId: sessionId,
- msg: json.encode(modify({'type': type, 'buttons': button.value})));
- }
-
/// Send mouse press event.
Future sendMouse(String type, MouseButtons button) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
- await _sendMouseUnchecked(type, button);
+ await bind.sessionSendMouse(
+ sessionId: sessionId,
+ msg: json.encode(modify({'type': type, 'buttons': button.value})));
}
void enterOrLeave(bool enter) {
toReleaseKeys.release(handleKeyEvent);
toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false;
- _pointerInsideImage = enter;
- _lastWheelTsUs = 0;
-
- // Track active model for side button events (Linux).
- if (enter) {
- _activeSideButtonModel = this;
- } else if (_activeSideButtonModel == this) {
- _activeSideButtonModel = null;
- }
// Fix status
if (!enter) {
resetModifiers();
}
- _relativeMouse.onEnterOrLeaveImage(enter);
_flingTimer?.cancel();
if (!isInputSourceFlutter) {
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
@@ -1170,142 +882,15 @@ class InputModel {
msg: json.encode(modify({'x': '$x2', 'y': '$y2'})));
}
- /// Send relative mouse movement for mobile clients (virtual joystick).
- /// This method is for touch-based controls that want to send delta values.
- /// Uses the 'move_relative' type which bypasses absolute position tracking.
- ///
- /// Accumulates fractional deltas to avoid losing slow/fine movements.
- /// Only sends events when relative mouse mode is enabled and supported.
- Future sendMobileRelativeMouseMove(double dx, double dy) async {
- if (!keyboardPerm) return;
- if (isViewCamera) return;
- // Only send relative mouse events when relative mode is enabled and supported.
- if (!isRelativeMouseModeSupported || !relativeMouseMode.value) return;
- _mobileDeltaRemainderX += dx;
- _mobileDeltaRemainderY += dy;
- final x = _mobileDeltaRemainderX.truncate();
- final y = _mobileDeltaRemainderY.truncate();
- _mobileDeltaRemainderX -= x;
- _mobileDeltaRemainderY -= y;
- if (x == 0 && y == 0) return;
- await bind.sessionSendMouse(
- sessionId: sessionId,
- msg: json.encode(modify({
- 'type': 'move_relative',
- 'x': '$x',
- 'y': '$y',
- })));
- }
-
- /// Update the pointer lock center position based on current window frame.
- Future updatePointerLockCenter({Offset? localCenter}) {
- return _relativeMouse.updatePointerLockCenter(localCenter: localCenter);
- }
-
- /// Get the current image widget size (for comparison to avoid unnecessary updates).
- Size? get imageWidgetSize => _relativeMouse.imageWidgetSize;
-
- /// Update the image widget size for center calculation.
- void updateImageWidgetSize(Size size) {
- _relativeMouse.updateImageWidgetSize(size);
- }
-
- void toggleRelativeMouseMode() {
- _relativeMouse.toggleRelativeMouseMode();
- }
-
- bool setRelativeMouseMode(bool enabled) {
- return _relativeMouse.setRelativeMouseMode(enabled);
- }
-
- /// Exit relative mouse mode and release all modifier keys to the remote.
- /// This is called when the user presses the exit shortcut (Ctrl+Alt on Win/Linux, Cmd+G on macOS).
- /// We need to send key-up events for all modifiers because the shortcut itself may have
- /// blocked some key events, leaving the remote in a state where modifiers are stuck.
- void exitRelativeMouseModeWithKeyRelease() {
- if (!_relativeMouse.enabled.value) return;
-
- // First, send release events for all modifier keys to the remote.
- // This ensures the remote doesn't have stuck modifier keys after exiting.
- // Use press: false, down: false to send key-up events without modifiers attached.
- final modifiersToRelease = [
- 'Control_L',
- 'Control_R',
- 'Alt_L',
- 'Alt_R',
- 'Shift_L',
- 'Shift_R',
- 'Meta_L', // Command/Super left
- 'Meta_R', // Command/Super right
- ];
-
- for (final key in modifiersToRelease) {
- bind.sessionInputKey(
- sessionId: sessionId,
- name: key,
- down: false,
- press: false,
- alt: false,
- ctrl: false,
- shift: false,
- command: false,
- );
- }
-
- // Reset local modifier state
- resetModifiers();
-
- // Now exit relative mouse mode
- _relativeMouse.setRelativeMouseMode(false);
- }
-
- void disposeRelativeMouseMode() {
- _relativeMouse.dispose();
- onRelativeMouseModeDisabled = null;
- // Cancel the relative mouse mode observer and clean up global state.
- _relativeMouseModeDisposer?.dispose();
- _relativeMouseModeDisposer = null;
- final peerId = id;
- if (peerId.isNotEmpty) {
- stateGlobal.relativeMouseModeState.remove(peerId);
- }
- }
-
- void onWindowBlur() {
- _relativeMouse.onWindowBlur();
- }
-
- void onWindowFocus() {
- _relativeMouse.onWindowFocus();
- }
-
void onPointHoverImage(PointerHoverEvent e) {
_stopFling = true;
if (isViewOnly && !showMyCursor) return;
if (e.kind != ui.PointerDeviceKind.mouse) return;
-
- // May fix https://github.com/rustdesk/rustdesk/issues/13009
- if (isIOS && e.synthesized && e.position == Offset.zero && e.buttons == 0) {
- // iOS may emit a synthesized hover event at (0,0) when the mouse is disconnected.
- // Ignore this event to prevent cursor jumping.
- debugPrint('Ignored synthesized hover at (0,0) on iOS');
- return;
- }
-
- // Only update pointer region when relative mouse mode is enabled.
- // This avoids unnecessary tracking when not in relative mode.
- if (_relativeMouse.enabled.value) {
- _relativeMouse.updatePointerRegionTopLeftGlobal(e);
- }
-
if (!isPhysicalMouse.value) {
isPhysicalMouse.value = true;
}
if (isPhysicalMouse.value) {
- if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
- handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
- edgeScroll: useEdgeScroll);
- }
+ handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll);
}
}
@@ -1341,7 +926,6 @@ class InputModel {
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
delta *= _trackpadAdjustMacToWin;
}
- delta = _filterTrackpadDeltaAxis(delta);
_trackpadLastDelta = delta;
var x = delta.dx.toInt();
@@ -1374,24 +958,6 @@ class InputModel {
}
}
- Offset _filterTrackpadDeltaAxis(Offset delta) {
- final absDx = delta.dx.abs();
- final absDy = delta.dy.abs();
- // Keep diagonal intent when movement is tiny on both axes.
- if (absDx < _trackpadAxisNoiseThreshold &&
- absDy < _trackpadAxisNoiseThreshold) {
- return delta;
- }
- // Dominant-axis lock to reduce accidental cross-axis scrolling noise.
- if (absDy >= absDx * _trackpadAxisLockRatio) {
- return Offset(0, delta.dy);
- }
- if (absDx >= absDy * _trackpadAxisLockRatio) {
- return Offset(delta.dx, 0);
- }
- return delta;
- }
-
void _scheduleFling(double x, double y, int delay) {
if (isViewCamera) return;
if ((x == 0 && y == 0) || _stopFling) {
@@ -1473,38 +1039,6 @@ class InputModel {
_trackpadLastDelta = Offset.zero;
}
- // iOS Magic Mouse duplicate event detection.
- // When using Magic Mouse on iPad, iOS may emit both mouse and touch events
- // for the same click in certain areas (like top-left corner).
- int _lastMouseDownTimeMs = 0;
- ui.Offset _lastMouseDownPos = ui.Offset.zero;
-
- /// Check if a touch tap event should be ignored because it's a duplicate
- /// of a recent mouse event (iOS Magic Mouse issue).
- bool shouldIgnoreTouchTap(ui.Offset pos) {
- if (!isIOS) return false;
- final nowMs = DateTime.now().millisecondsSinceEpoch;
- final dt = nowMs - _lastMouseDownTimeMs;
- final distance = (_lastMouseDownPos - pos).distance;
- // If touch tap is within 2000ms and 80px of the last mouse down,
- // it's likely a duplicate event from the same Magic Mouse click.
- if (dt >= 0 && dt < 2000 && distance < 80.0) {
- debugPrint("shouldIgnoreTouchTap: IGNORED (dt=$dt, dist=$distance)");
- return true;
- }
- return false;
- }
-
- /// iOS may emit a synthesized touch event after a real mouse click.
- /// This helper ignores touch-down events that arrive shortly after a mouse down,
- /// even when the position is far (e.g., near the top edge).
- bool _shouldIgnoreTouchAfterMouse(int nowMs) {
- if (!isIOS) return false;
- const int kTouchAfterMouseWindowMs = 700;
- final dt = nowMs - _lastMouseDownTimeMs;
- return dt >= 0 && dt < kTouchAfterMouseWindowMs;
- }
-
void onPointDownImage(PointerDownEvent e) {
debugPrint("onPointDownImage ${e.kind}");
_stopFling = true;
@@ -1513,39 +1047,13 @@ class InputModel {
_windowRect = null;
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
-
- // Track mouse down events for duplicate detection on iOS.
- final nowMs = DateTime.now().millisecondsSinceEpoch;
- if (e.kind == ui.PointerDeviceKind.mouse) {
- if (!isPhysicalMouse.value) {
- isPhysicalMouse.value = true;
- }
- _lastMouseDownTimeMs = nowMs;
- _lastMouseDownPos = e.position;
- }
-
- if (_relativeMouse.enabled.value) {
- _relativeMouse.updatePointerRegionTopLeftGlobal(e);
- }
-
if (e.kind != ui.PointerDeviceKind.mouse) {
- // Ignore duplicate touch events that follow a recent mouse click (iOS Magic Mouse issue).
- if (isPhysicalMouse.value && _shouldIgnoreTouchAfterMouse(nowMs)) {
- return;
- }
if (isPhysicalMouse.value) {
isPhysicalMouse.value = false;
}
}
if (isPhysicalMouse.value) {
- // In relative mouse mode, send button events without position.
- // Use _relativeMouse.enabled.value consistently with the guard above.
- if (_relativeMouse.enabled.value) {
- _relativeMouse
- .sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
- } else {
- handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
- }
+ handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
}
}
@@ -1553,21 +1061,9 @@ class InputModel {
if (isDesktop) _queryOtherWindowCoords = false;
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
-
- if (_relativeMouse.enabled.value) {
- _relativeMouse.updatePointerRegionTopLeftGlobal(e);
- }
-
if (e.kind != ui.PointerDeviceKind.mouse) return;
if (isPhysicalMouse.value) {
- // In relative mouse mode, send button events without position.
- // Use _relativeMouse.enabled.value consistently with the guard above.
- if (_relativeMouse.enabled.value) {
- _relativeMouse
- .sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
- } else {
- handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
- }
+ handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
}
}
@@ -1575,11 +1071,6 @@ class InputModel {
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
if (e.kind != ui.PointerDeviceKind.mouse) return;
-
- if (_relativeMouse.enabled.value) {
- _relativeMouse.updatePointerRegionTopLeftGlobal(e);
- }
-
if (_queryOtherWindowCoords) {
Future.delayed(Duration.zero, () async {
_windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
@@ -1587,10 +1078,7 @@ class InputModel {
_queryOtherWindowCoords = false;
}
if (isPhysicalMouse.value) {
- if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
- handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
- edgeScroll: useEdgeScroll);
- }
+ handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll);
}
}
@@ -1614,53 +1102,21 @@ class InputModel {
return null;
}
- /// Handle scroll/wheel events.
- /// Note: Scroll events intentionally use absolute positioning even in relative mouse mode.
- /// This is because scroll events don't need relative positioning - they represent
- /// scroll deltas that are independent of cursor position. Games and 3D applications
- /// handle scroll events the same way regardless of mouse mode.
void onPointerSignalImage(PointerSignalEvent e) {
if (isViewOnly) return;
if (isViewCamera) return;
if (e is PointerScrollEvent) {
- final rawDx = e.scrollDelta.dx;
- final rawDy = e.scrollDelta.dy;
- final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs();
- final isSmooth = dominantDelta < 1;
- final nowUs = DateTime.now().microsecondsSinceEpoch;
- final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs;
- _lastWheelTsUs = nowUs;
- int accel = 1;
- if (!isSmooth &&
- dtUs > 0 &&
- dtUs <= _wheelAccelMediumThresholdUs &&
- (isWindows || isLinux) &&
- peerPlatform == kPeerPlatformMacOS) {
- final velocity = dominantDelta / dtUs;
- if (velocity >= _wheelBurstVelocityThreshold) {
- if (dtUs < _wheelAccelFastThresholdUs) {
- accel = 3;
- } else {
- accel = 2;
- }
- }
- }
- var dx = rawDx.toInt();
- var dy = rawDy.toInt();
- if (rawDx.abs() > rawDy.abs()) {
- dy = 0;
- } else {
- dx = 0;
- }
+ var dx = e.scrollDelta.dx.toInt();
+ var dy = e.scrollDelta.dy.toInt();
if (dx > 0) {
- dx = -accel;
+ dx = -1;
} else if (dx < 0) {
- dx = accel;
+ dx = 1;
}
if (dy > 0) {
- dy = -accel;
+ dy = -1;
} else if (dy < 0) {
- dy = accel;
+ dy = 1;
}
bind.sessionSendMouse(
sessionId: sessionId,
@@ -1833,18 +1289,14 @@ class InputModel {
evt['y'] = '${pos.y.toInt()}';
}
- final buttons = evt['buttons'];
- if (buttons is int) {
- evt['buttons'] = mouseButtonsToPeer(buttons);
- } else {
- // Log warning if buttons exists but is not an int (unexpected caller).
- // Keep empty string fallback for missing buttons to preserve move/hover behavior.
- if (buttons != null) {
- debugPrint(
- '[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons');
- }
- evt['buttons'] = '';
- }
+ Map mapButtons = {
+ kPrimaryMouseButton: 'left',
+ kSecondaryMouseButton: 'right',
+ kMiddleMouseButton: 'wheel',
+ kBackMouseButton: 'back',
+ kForwardMouseButton: 'forward'
+ };
+ evt['buttons'] = mapButtons[evt['buttons']] ?? '';
return evt;
}
@@ -1855,8 +1307,8 @@ class InputModel {
bool moveCanvas = true,
bool edgeScroll = false,
}) {
- final evtToPeer = processEventToPeer(evt, offset,
- onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
+ final evtToPeer =
+ processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
if (evtToPeer != null) {
bind.sessionSendMouse(
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
@@ -2096,9 +1548,9 @@ class InputModel {
// Simulate a key press event.
// `usbHidUsage` is the USB HID usage code of the key.
Future tapHidKey(int usbHidUsage) async {
- newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false);
+ newKeyboardMode(kKeyFlutterKey, usbHidUsage, true);
await Future.delayed(Duration(milliseconds: 100));
- newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false);
+ newKeyboardMode(kKeyFlutterKey, usbHidUsage, false);
}
Future onMobileVolumeUp() async =>
diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart
deleted file mode 100644
index e65c32790..000000000
--- a/flutter/lib/models/input_modifier_utils.dart
+++ /dev/null
@@ -1,38 +0,0 @@
-import 'package:flutter/services.dart';
-
-/// Returns true when a stale mobile one-shot Shift state should be released
-/// by replaying a tracked Shift key-down as a synthesized key-up.
-///
-/// This is only valid on mobile when Flutter's cached Shift state is still on
-/// (`cachedShiftPressed == true`) but the current hardware/raw event reports
-/// Shift as off (`actualShiftPressed == false`).
-///
-/// A tracked Shift key-down is required so the caller can safely synthesize the
-/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the
-/// Shift key event itself must be processed first; otherwise we could release
-/// the tracked key while still handling the original Shift press/release.
-/// Callers should evaluate this only after their cached modifier state has been
-/// updated for the current event.
-///
-/// When this returns true, the caller logs a line like:
-/// `input: releasing stale mobile Shift before replaying tracked raw key-up`
-/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`.
-bool shouldReleaseStaleMobileShift({
- required bool isMobile,
- required bool cachedShiftPressed,
- required bool actualShiftPressed,
- required LogicalKeyboardKey logicalKey,
- required bool hasTrackedShiftKeyDown,
-}) {
- if (!isMobile || !cachedShiftPressed || actualShiftPressed) {
- return false;
- }
- if (!hasTrackedShiftKeyDown) {
- return false;
- }
- if (logicalKey == LogicalKeyboardKey.shiftLeft ||
- logicalKey == LogicalKeyboardKey.shiftRight) {
- return false;
- }
- return true;
-}
diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart
index e94834a2b..b6d98a01c 100644
--- a/flutter/lib/models/model.dart
+++ b/flutter/lib/models/model.dart
@@ -120,7 +120,6 @@ class FfiModel with ChangeNotifier {
late VirtualMouseMode virtualMouseMode;
Timer? _timer;
var _reconnects = 1;
- DateTime? _offlineReconnectStartTime;
bool _viewOnly = false;
bool _showMyCursor = false;
WeakReference parent;
@@ -214,9 +213,6 @@ class FfiModel with ChangeNotifier {
}
updatePermission(Map evt, String id) {
- // Track previous keyboard permission to detect revocation.
- final hadKeyboardPerm = _permissions['keyboard'] != false;
-
evt.forEach((k, v) {
if (k == 'name' || k.isEmpty) return;
_permissions[k] = v == 'true';
@@ -225,18 +221,6 @@ class FfiModel with ChangeNotifier {
if (parent.target?.connType == ConnType.defaultConn) {
KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false;
}
-
- // If keyboard permission was revoked while relative mouse mode is active,
- // forcefully disable relative mouse mode to prevent the user from being trapped.
- final hasKeyboardPerm = _permissions['keyboard'] != false;
- if (hadKeyboardPerm && !hasKeyboardPerm) {
- final inputModel = parent.target?.inputModel;
- if (inputModel != null && inputModel.relativeMouseMode.value) {
- inputModel.setRelativeMouseMode(false);
- showToast(translate('rel-mouse-permission-lost-tip'));
- }
- }
-
debugPrint('updatePermission: $_permissions');
notifyListeners();
}
@@ -379,7 +363,7 @@ class FfiModel with ChangeNotifier {
parent.target?.fileModel.refreshAll();
}
} else if (name == 'job_error') {
- parent.target?.fileModel.handleJobError(evt);
+ parent.target?.fileModel.jobController.jobError(evt);
} else if (name == 'override_file_confirm') {
parent.target?.fileModel.postOverrideFileConfirm(evt);
} else if (name == 'load_last_job') {
@@ -473,9 +457,6 @@ class FfiModel with ChangeNotifier {
_handlePrinterRequest(evt, sessionId, peerId);
} else if (name == 'screenshot') {
_handleScreenshot(evt, sessionId, peerId);
- } else if (name == 'exit_relative_mouse_mode') {
- // Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
- parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
} else {
debugPrint('Event is not handled in the fixed branch: $name');
}
@@ -784,8 +765,7 @@ class FfiModel with ChangeNotifier {
}
}
- Future updateCurDisplay(SessionID sessionId,
- {updateCursorPos = false}) async {
+ updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) {
final newRect = displaysRect();
if (newRect == null) {
return;
@@ -797,19 +777,9 @@ class FfiModel with ChangeNotifier {
updateCursorPos: updateCursorPos);
}
_rect = newRect;
- // Await updateViewStyle to ensure view geometry is fully updated before
- // updating pointer lock center. This prevents stale center calculations.
- await parent.target?.canvasModel
+ parent.target?.canvasModel
.updateViewStyle(refreshMousePos: updateCursorPos);
_updateSessionWidthHeight(sessionId);
-
- // Keep pointer lock center in sync when using relative mouse mode.
- // Note: updatePointerLockCenter is async-safe (handles errors internally),
- // so we fire-and-forget here.
- final inputModel = parent.target?.inputModel;
- if (inputModel != null && inputModel.relativeMouseMode.value) {
- inputModel.updatePointerLockCenter();
- }
}
}
@@ -893,17 +863,6 @@ class FfiModel with ChangeNotifier {
final title = evt['title'];
final text = evt['text'];
final link = evt['link'];
-
- // Disable relative mouse mode on any error-type message to ensure cursor is released.
- // This includes connection errors, session-ending messages, elevation errors, etc.
- // Safety: releasing pointer lock on errors prevents the user from being stuck.
- if (title == 'Connection Error' ||
- type == 'error' ||
- type == 'restarting' ||
- (type is String && type.contains('error'))) {
- parent.target?.inputModel.setRelativeMouseMode(false);
- }
-
if (type == 're-input-password') {
wrongPasswordDialog(sessionId, dialogManager, type, title, text);
} else if (type == 'input-2fa') {
@@ -941,46 +900,11 @@ class FfiModel with ChangeNotifier {
showPrivacyFailedDialog(
sessionId, type, title, text, link, hasRetry, dialogManager);
} else {
- var hasRetry = evt['hasRetry'] == 'true';
- if (!hasRetry) {
- hasRetry = shouldAutoRetryOnOffline(type, title, text);
- }
+ final hasRetry = evt['hasRetry'] == 'true';
showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager);
}
}
- /// Auto-retry check for "Remote desktop is offline" error.
- /// returns true to auto-retry, false otherwise.
- bool shouldAutoRetryOnOffline(
- String type,
- String title,
- String text,
- ) {
- if (type == 'error' &&
- title == 'Connection Error' &&
- text == 'Remote desktop is offline' &&
- _pi.isSet.isTrue) {
- // Auto retry for ~30s (server's peer offline threshold) when controlled peer's account changes
- // (e.g., signout, switch user, login into OS) causes temporary offline via websocket/tcp connection.
- // The actual wait may exceed 30s (e.g., 20s elapsed + 16s next retry = 36s), which is acceptable
- // since the controlled side reconnects quickly after account changes.
- // Uses time-based check instead of _reconnects count because user can manually retry.
- // https://github.com/rustdesk/rustdesk/discussions/14048
- if (_offlineReconnectStartTime == null) {
- // First offline, record time and start retry
- _offlineReconnectStartTime = DateTime.now();
- return true;
- } else {
- final elapsed =
- DateTime.now().difference(_offlineReconnectStartTime!).inSeconds;
- if (elapsed < 30) {
- return true;
- }
- }
- }
- return false;
- }
-
handleToast(Map evt, SessionID sessionId, String peerId) {
final type = evt['type'] ?? 'info';
final text = evt['text'] ?? '';
@@ -1016,31 +940,19 @@ class FfiModel with ChangeNotifier {
showMsgBox(SessionID sessionId, String type, String title, String text,
String link, bool hasRetry, OverlayDialogManager dialogManager,
{bool? hasCancel}) async {
- final noteAllowed = parent.target != null &&
+ final showNoteEdit = parent.target != null &&
allowAskForNoteAtEndOfConnection(parent.target, false) &&
- (title == "Connection Error" || type == "restarting");
- final showNoteEdit = noteAllowed && !hasRetry;
+ (title == "Connection Error" || type == "restarting") &&
+ !hasRetry;
if (showNoteEdit) {
await showConnEndAuditDialogCloseCanceled(
ffi: parent.target!, type: type, title: title, text: text);
closeConnection();
} else {
- VoidCallback? onSubmit;
- if (noteAllowed && hasRetry) {
- final ffi = parent.target!;
- onSubmit = () async {
- _timer?.cancel();
- _timer = null;
- await showConnEndAuditDialogCloseCanceled(
- ffi: ffi, type: type, title: title, text: text);
- closeConnection();
- };
- }
msgBox(sessionId, type, title, text, link, dialogManager,
hasCancel: hasCancel,
reconnect: hasRetry ? reconnect : null,
- reconnectTimeout: hasRetry ? _reconnects : null,
- onSubmit: onSubmit);
+ reconnectTimeout: hasRetry ? _reconnects : null);
}
_timer?.cancel();
if (hasRetry) {
@@ -1050,14 +962,11 @@ class FfiModel with ChangeNotifier {
_reconnects *= 2;
} else {
_reconnects = 1;
- _offlineReconnectStartTime = null;
}
}
void reconnect(OverlayDialogManager dialogManager, SessionID sessionId,
bool forceRelay) {
- // Disable relative mouse mode before reconnecting to ensure cursor is released.
- parent.target?.inputModel.setRelativeMouseMode(false);
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
clearPermissions();
dialogManager.dismissAll();
@@ -1172,8 +1081,7 @@ class FfiModel with ChangeNotifier {
if (displays.length == 1) {
bind.sessionSetSize(
sessionId: sessionId,
- display:
- pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
+ display: pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
width: displays[0].width,
height: displays[0].height,
);
@@ -1192,14 +1100,6 @@ class FfiModel with ChangeNotifier {
void _queryAuditGuid(String peerId) async {
try {
- if (bind.isDisableAccount()) {
- return;
- }
- if (bind
- .sessionGetAuditServerSync(sessionId: sessionId, typ: "conn/active")
- .isEmpty) {
- return;
- }
if (!mainGetLocalBoolOptionSync(
kOptionAllowAskForNoteAtEndOfConnection)) {
return;
@@ -1283,6 +1183,9 @@ class FfiModel with ChangeNotifier {
_queryAuditGuid(peerId);
+ // This call is to ensuer the keyboard mode is updated depending on the peer version.
+ parent.target?.inputModel.updateKeyboardMode();
+
// Map clone is required here, otherwise "evt" may be changed by other threads through the reference.
// Because this function is asynchronous, there's an "await" in this function.
cachedPeerData.peerInfo = {...evt};
@@ -1294,17 +1197,6 @@ class FfiModel with ChangeNotifier {
parent.target?.dialogManager.dismissAll();
_pi.version = evt['version'];
- // Note: Relative mouse mode is NOT auto-enabled on connect.
- // Users must manually enable it via toolbar or keyboard shortcut (Ctrl+Alt+Shift+M).
- //
- // For desktop/webDesktop, keyboard mode initialization is handled later by
- // checkDesktopKeyboardMode() which may change the mode if not supported,
- // followed by updateKeyboardMode() to sync InputModel.keyboardMode.
- // For mobile, updateKeyboardMode() is currently a no-op (only executes on desktop/web),
- // but we call it here for consistency and future-proofing.
- if (isMobile) {
- parent.target?.inputModel.updateKeyboardMode();
- }
_pi.isSupportMultiUiSession =
bind.isSupportMultiUiSession(version: _pi.version);
_pi.username = evt['username'];
@@ -1373,7 +1265,6 @@ class FfiModel with ChangeNotifier {
}
if (displays.isNotEmpty) {
_reconnects = 1;
- _offlineReconnectStartTime = null;
waitForFirstImage.value = true;
isRefreshing = false;
}
@@ -1407,11 +1298,7 @@ class FfiModel with ChangeNotifier {
stateGlobal.resetLastResolutionGroupValues(peerId);
if (isDesktop || isWebDesktop) {
- // checkDesktopKeyboardMode may change the keyboard mode if the current
- // mode is not supported. Re-sync InputModel.keyboardMode afterwards.
- // Note: updateKeyboardMode() is a no-op on mobile (early-returns).
- await checkDesktopKeyboardMode();
- await parent.target?.inputModel.updateKeyboardMode();
+ checkDesktopKeyboardMode();
}
notifyListeners();
@@ -2164,9 +2051,6 @@ class CanvasModel with ChangeNotifier {
ViewStyle _lastViewStyle = ViewStyle.defaultViewStyle();
Timer? _timerMobileFocusCanvasCursor;
- Timer? _timerMobileRestoreCanvasOffset;
- Offset? _offsetBeforeMobileSoftKeyboard;
- double? _scaleBeforeMobileSoftKeyboard;
// `isMobileCanvasChanged` is used to avoid canvas reset when changing the input method
// after showing the soft keyboard.
@@ -2230,32 +2114,10 @@ class CanvasModel with ChangeNotifier {
double w = size.width - leftToEdge - rightToEdge;
double h = size.height - topToEdge - bottomToEdge;
if (isMobile) {
- // Account for horizontal safe area insets on both orientations.
- w = w - mediaData.padding.left - mediaData.padding.right;
- // Vertically, subtract the bottom keyboard inset (viewInsets.bottom) and any
- // bottom overlay (e.g. key-help tools) so the canvas is not covered.
h = h -
mediaData.viewInsets.bottom -
(parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ??
0);
- // Orientation-specific handling:
- // - Portrait: additionally subtract top padding (e.g. status bar / notch)
- // - Landscape: does not subtract mediaData.padding.top/bottom (home indicator auto-hides)
- final isPortrait = size.height > size.width;
- if (isPortrait) {
- // In portrait mode, subtract the top safe-area padding (e.g. status bar / notch)
- // so the remote image is not truncated, while keeping the bottom inset to avoid
- // introducing unnecessary blank space around the canvas.
- //
- // iOS -> Android, portrait, adjust mode:
- // h = h (no padding subtracted): top and bottom are truncated
- // https://github.com/user-attachments/assets/30ed4559-c27e-432b-847f-8fec23c9f998
- // h = h - top - bottom: extra blank spaces appear
- // https://github.com/user-attachments/assets/12a98817-3b4e-43aa-be0f-4b03cf364b7e
- // h = h - top (current): works fine
- // https://github.com/user-attachments/assets/95f047f2-7f47-4a36-8113-5023989a0c81
- h = h - mediaData.padding.top;
- }
}
return Size(w < 0 ? 0 : w, h < 0 ? 0 : h);
}
@@ -2654,9 +2516,6 @@ class CanvasModel with ChangeNotifier {
_scale = 1.0;
_lastViewStyle = ViewStyle.defaultViewStyle();
_timerMobileFocusCanvasCursor?.cancel();
- _timerMobileRestoreCanvasOffset?.cancel();
- _offsetBeforeMobileSoftKeyboard = null;
- _scaleBeforeMobileSoftKeyboard = null;
}
updateScrollPercent() {
@@ -2685,31 +2544,6 @@ class CanvasModel with ChangeNotifier {
});
}
- void saveMobileOffsetBeforeSoftKeyboard() {
- _timerMobileRestoreCanvasOffset?.cancel();
- _offsetBeforeMobileSoftKeyboard = Offset(_x, _y);
- _scaleBeforeMobileSoftKeyboard = _scale;
- }
-
- void restoreMobileOffsetAfterSoftKeyboard() {
- _timerMobileRestoreCanvasOffset?.cancel();
- _timerMobileFocusCanvasCursor?.cancel();
- final targetOffset = _offsetBeforeMobileSoftKeyboard;
- final targetScale = _scaleBeforeMobileSoftKeyboard;
- if (targetOffset == null || targetScale == null) {
- return;
- }
- _timerMobileRestoreCanvasOffset = Timer(Duration(milliseconds: 100), () {
- updateSize();
- _x = targetOffset.dx;
- _y = targetOffset.dy;
- _scale = targetScale;
- _offsetBeforeMobileSoftKeyboard = null;
- _scaleBeforeMobileSoftKeyboard = null;
- notifyListeners();
- });
- }
-
// mobile only
// Move the canvas to make the cursor visible(center) on the screen.
void _moveToCenterCursor() {
@@ -2962,13 +2796,8 @@ class CursorModel with ChangeNotifier {
_lastIsBlocked = true;
}
if (isMobile && _lastKeyboardIsVisible != keyboardIsVisible) {
- if (keyboardIsVisible) {
- parent.target?.canvasModel.saveMobileOffsetBeforeSoftKeyboard();
- parent.target?.canvasModel.mobileFocusCanvasCursor();
- parent.target?.canvasModel.isMobileCanvasChanged = false;
- } else {
- parent.target?.canvasModel.restoreMobileOffsetAfterSoftKeyboard();
- }
+ parent.target?.canvasModel.mobileFocusCanvasCursor();
+ parent.target?.canvasModel.isMobileCanvasChanged = false;
}
_lastKeyboardIsVisible = keyboardIsVisible;
}
@@ -3930,9 +3759,6 @@ class FFI {
ffiModel.clear();
canvasModel.clear();
inputModel.resetModifiers();
- // Dispose relative mouse mode resources to ensure cursor is restored
- inputModel.disposeRelativeMouseMode();
- inputModel.disposeSideButtonTracking();
if (closeSession) {
await bind.sessionClose(sessionId: sessionId);
}
diff --git a/flutter/lib/models/relative_mouse_model.dart b/flutter/lib/models/relative_mouse_model.dart
deleted file mode 100644
index 2673cb8ae..000000000
--- a/flutter/lib/models/relative_mouse_model.dart
+++ /dev/null
@@ -1,1061 +0,0 @@
-import 'dart:async';
-import 'dart:convert';
-import 'dart:math' as math;
-import 'dart:ui' as ui;
-
-import 'package:desktop_multi_window/desktop_multi_window.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter/widgets.dart';
-import 'package:flutter_hbb/main.dart';
-import 'package:flutter_hbb/utils/relative_mouse_accumulator.dart';
-import 'package:get/get.dart';
-
-import '../common.dart';
-import '../consts.dart';
-import 'platform_model.dart';
-
-class RelativeMouseModel {
- final SessionID sessionId;
- final RxBool enabled;
-
- final bool Function() keyboardPerm;
- final bool Function() isViewCamera;
- final String Function() peerVersion;
- final String? Function() peerPlatform;
-
- final Map Function(Map msg) modify;
-
- final bool Function() getPointerInsideImage;
- final void Function(bool inside) setPointerInsideImage;
-
- RelativeMouseModel({
- required this.sessionId,
- required this.enabled,
- required this.keyboardPerm,
- required this.isViewCamera,
- required this.peerVersion,
- required this.peerPlatform,
- required this.modify,
- required this.getPointerInsideImage,
- required this.setPointerInsideImage,
- });
-
- final RelativeMouseAccumulator _accumulator = RelativeMouseAccumulator();
-
- // Native relative mouse mode support (macOS only)
- // Uses CGAssociateMouseAndMouseCursorPosition to lock cursor and NSEvent monitor for raw delta.
- static MethodChannel? _hostChannel;
- // The currently active model receiving native mouse delta events.
- // Note: Race condition between multiple sessions is not a concern here because
- // when relative mouse mode is active, the cursor is locked and the user cannot
- // switch to another session window. The user must first exit relative mouse mode
- // (via Cmd+G on macOS or Ctrl+Alt on Windows/Linux) before they can interact
- // with a different session.
- static RelativeMouseModel? _activeNativeModel;
- static bool _hostChannelInitialized = false;
-
- /// Initialize the host channel for native relative mouse mode.
- /// This should be called once when the app starts on macOS.
- static void initHostChannel() {
- if (!isMacOS) return;
- if (_hostChannelInitialized) return;
- _hostChannelInitialized = true;
-
- _hostChannel = const MethodChannel('org.rustdesk.rustdesk/host');
- _hostChannel!.setMethodCallHandler((call) async {
- if (call.method == 'onMouseDelta') {
- final args = call.arguments as Map;
- final dx = args['dx'] as int;
- final dy = args['dy'] as int;
- _activeNativeModel?._onNativeMouseDelta(dx, dy);
- }
- return null;
- });
- }
-
- // TODO(perf): Consider routing native delta through RelativeMouseAccumulator/throttle
- // if high-polling mice (e.g. 1000Hz+) cause message flooding on the network.
- void _onNativeMouseDelta(int dx, int dy) {
- if (!enabled.value) return;
- // Send directly to remote without accumulator (native already provides integer deltas)
- _sendMouseMessageToSession({
- 'type': 'move_relative',
- 'x': '$dx',
- 'y': '$dy',
- });
- }
-
- Future _enableNativeRelativeMouseMode() async {
- if (!isMacOS) return false;
- if (_hostChannel == null) {
- initHostChannel();
- if (_hostChannel == null) return false;
- }
-
- // Defensive guard: prevent overwriting an already-active native session.
- // In practice, this should not happen because when relative mouse mode is active,
- // the cursor is locked and the user cannot switch to another session window.
- // The user must first exit relative mouse mode (via Cmd+G on macOS or Ctrl+Alt on
- // Windows/Linux) before interacting with a different session.
- if (_activeNativeModel != null && _activeNativeModel != this) {
- debugPrint(
- '[RelMouse] Another model already has native relative mouse mode active');
- return false;
- }
-
- try {
- final result =
- await _hostChannel!.invokeMethod('enableNativeRelativeMouseMode');
- if (result == true) {
- _activeNativeModel = this;
- return true;
- }
- } catch (e) {
- debugPrint('[RelMouse] Failed to enable native relative mouse mode: $e');
- }
- return false;
- }
-
- Future _disableNativeRelativeMouseMode() async {
- if (!isMacOS) return;
- if (_hostChannel == null) return;
-
- // Only the owning model should disable native mode to avoid
- // one session inadvertently disrupting another's native relative mouse state.
- if (_activeNativeModel != this) {
- return;
- }
-
- try {
- await _hostChannel!.invokeMethod('disableNativeRelativeMouseMode');
- } catch (e) {
- debugPrint('[RelMouse] Failed to disable native relative mouse mode: $e');
- } finally {
- if (_activeNativeModel == this) {
- _activeNativeModel = null;
- }
- }
- }
-
- // Whether native relative mouse mode is currently active for this model
- bool get _isNativeRelativeMouseModeActive =>
- isMacOS && _activeNativeModel == this;
-
- // Pointer lock center in LOCAL widget coordinates (for delta calculation)
- Offset? _pointerLockCenterLocal;
- // Pointer lock center in SCREEN coordinates (for OS cursor re-centering)
- Offset? _pointerLockCenterScreen;
- // Pointer region top-left in Flutter view coordinates.
- // Computed from PointerEvent.position - PointerEvent.localPosition.
- Offset? _pointerRegionTopLeftGlobal;
- // Last pointer position in LOCAL widget coordinates (fallback when center is not ready).
- Offset? _lastPointerLocalPos;
-
- // Track whether we currently have an OS-level cursor clip active (Windows only).
- // TODO(accuracy): Revisit window/client/border clipping math if users report misaligned
- // clipping on custom or maximized window decorations. Consider using platform APIs
- // (e.g. GetClientRect on Windows) instead of Flutter's window coordinates.
- bool _cursorClipApplied = false;
-
- // Track whether a recenter operation is in progress to prevent overlapping calls.
- bool _recenterInProgress = false;
-
- // Request token for async enable operation to prevent stale callbacks.
- // Incremented on each enable attempt, callbacks check if token still matches.
- int _enableRequestId = 0;
-
- // Throttle buffer for batching mouse move messages (reduces network flooding).
- int _pendingDeltaX = 0;
- int _pendingDeltaY = 0;
- Timer? _throttleTimer;
- static const Duration _throttleInterval = Duration(milliseconds: 16);
-
- // Size of the remote image widget (for center calculation)
- Size? _imageWidgetSize;
-
- // Debounce timestamp for relative mouse mode toggle to prevent race conditions
- // between Rust rdev grab loop and Flutter keyboard handling.
- DateTime? _lastToggle;
-
- // Track key down state for exit shortcut.
- // macOS: Cmd+G - track G key
- // Windows/Linux: Ctrl+Alt - track whichever modifier was pressed last
- // When key down is blocked (shortcut triggered), we also need to block
- // the corresponding key up to avoid orphan key up events being sent to remote.
- bool _exitShortcutKeyDown = false;
-
- // Callback to cancel external throttle timer when relative mouse mode is disabled.
- VoidCallback? onDisabled;
-
- bool get isSupported {
- // On Linux/Wayland, cursor warping is not supported, hide the option entirely.
- if (isDesktop && isLinux && bind.mainCurrentIsWayland()) {
- return false;
- }
- // Relative mouse mode is unsupported on remote Linux:
- // 1. Long-press key events are unsupported.
- // 2. The Wayland display server lacks cursor warping support.
- final platform = peerPlatform();
- if (platform == kPeerPlatformLinux) {
- return false;
- }
- final v = peerVersion();
- if (v.isEmpty) return false;
- return versionCmp(v, kMinVersionForRelativeMouseMode) >= 0;
- }
-
- Size? get imageWidgetSize => _imageWidgetSize;
-
- void updateImageWidgetSize(Size size) {
- _imageWidgetSize = size;
- if (enabled.value) {
- _pointerLockCenterLocal = Offset(size.width / 2, size.height / 2);
- }
- }
-
- void updatePointerRegionTopLeftGlobal(PointerEvent e) {
- _pointerRegionTopLeftGlobal = e.position - e.localPosition;
- }
-
- /// Shared helper for handling exit shortcut for relative mouse mode.
- /// Returns true if the event was handled and should not be forwarded.
- ///
- /// Exit shortcuts (only work when relative mouse mode is active):
- /// - macOS: Cmd+G
- /// - Windows/Linux: Ctrl+Alt (any order - triggered when both are pressed)
- ///
- /// [logicalKey] - the logical key of the event
- /// [isKeyUp] - whether the event is a key up event
- /// [isKeyDown] - whether the event is a key down event
- /// [ctrlPressed], [altPressed], [commandPressed] - modifier states
- bool _handleExitShortcut({
- required LogicalKeyboardKey logicalKey,
- required bool isKeyUp,
- required bool isKeyDown,
- required bool ctrlPressed,
- required bool altPressed,
- required bool commandPressed,
- }) {
- if (!isDesktop || !keyboardPerm() || isViewCamera()) return false;
-
- // Only handle exit shortcuts when relative mouse mode is active
- if (!enabled.value) return false;
-
- // Block key up if key down was blocked (to avoid orphan key up event on remote).
- if (isKeyUp && _exitShortcutKeyDown) {
- _exitShortcutKeyDown = false;
- return true;
- }
-
- if (!isKeyDown) return false;
-
- // macOS: Cmd+G to exit
- if (isMacOS) {
- final isGKey = logicalKey == LogicalKeyboardKey.keyG;
- if (isGKey && commandPressed) {
- _exitShortcutKeyDown = true;
- setRelativeMouseMode(false);
- return true;
- }
- return false;
- }
-
- // Windows/Linux: Ctrl+Alt to exit
- // Triggered when both modifiers are pressed (check on either Ctrl or Alt key down)
- final isCtrlKey = logicalKey == LogicalKeyboardKey.controlLeft ||
- logicalKey == LogicalKeyboardKey.controlRight;
- final isAltKey = logicalKey == LogicalKeyboardKey.altLeft ||
- logicalKey == LogicalKeyboardKey.altRight;
-
- // When Ctrl is pressed and Alt is already down, or vice versa
- if ((isCtrlKey && altPressed) || (isAltKey && ctrlPressed)) {
- _exitShortcutKeyDown = true;
- setRelativeMouseMode(false);
- return true;
- }
-
- return false;
- }
-
- bool handleKeyEvent(
- KeyEvent e, {
- required bool ctrlPressed,
- required bool shiftPressed,
- required bool altPressed,
- required bool commandPressed,
- }) {
- return _handleExitShortcut(
- logicalKey: e.logicalKey,
- isKeyUp: e is KeyUpEvent,
- isKeyDown: e is KeyDownEvent,
- ctrlPressed: ctrlPressed,
- altPressed: altPressed,
- commandPressed: commandPressed,
- );
- }
-
- /// Handle raw key events for relative mouse mode.
- /// Returns true if the event was handled and should not be forwarded.
- bool handleRawKeyEvent(RawKeyEvent e) {
- final modifiers = e.data;
- return _handleExitShortcut(
- logicalKey: e.logicalKey,
- isKeyUp: e is RawKeyUpEvent,
- isKeyDown: e is RawKeyDownEvent,
- ctrlPressed: modifiers.isControlPressed,
- altPressed: modifiers.isAltPressed,
- commandPressed: modifiers.isMetaPressed,
- );
- }
-
- void onEnterOrLeaveImage(bool enter) {
- if (!enabled.value) return;
-
- // Keep the shared pointer-in-image flag in sync.
- setPointerInsideImage(enter);
-
- // macOS native mode: cursor is locked by CGAssociateMouseAndMouseCursorPosition,
- // no need for recenter logic.
- if (_isNativeRelativeMouseModeActive) {
- return;
- }
-
- if (!enter) {
- _releaseCursorClip();
- return;
- }
-
- // Windows: clip cursor to window rect
- // Linux: use recenter method
- updatePointerLockCenter().then((_) {
- _recenterMouse();
- });
- }
-
- void onWindowBlur() {
- if (!enabled.value) return;
-
- // Focus can change while the pointer is outside the window (e.g. taskbar activation).
- // Do not rely on the previous "pointer inside" state across focus boundaries.
- setPointerInsideImage(false);
- // macOS native mode: don't call _releaseCursorClip as it would break CGAssociateMouseAndMouseCursorPosition
- if (!_isNativeRelativeMouseModeActive) {
- _releaseCursorClip();
- }
- }
-
- void onWindowFocus() {
- if (!enabled.value) return;
-
- // macOS native mode: cursor is already locked
- if (_isNativeRelativeMouseModeActive) {
- setPointerInsideImage(false);
- return;
- }
-
- // Guard: image widget size must be available for proper center calculation.
- if (_imageWidgetSize == null) {
- _disableWithCleanup();
- return;
- }
-
- // Fail-safe: keep cursor usable on focus gain. Pointer lock will be re-engaged
- // on the next pointer enter/move/hover inside the remote image.
- setPointerInsideImage(false);
- _releaseCursorClip();
-
- // Best-effort: refresh center so the next engage is immediate.
- updatePointerLockCenter();
- }
-
- void toggleRelativeMouseMode() {
- final now = DateTime.now();
- if (_lastToggle != null &&
- now.difference(_lastToggle!).inMilliseconds <
- kRelativeMouseModeToggleDebounceMs) {
- return;
- }
- _lastToggle = now;
- setRelativeMouseMode(!enabled.value);
- }
-
- bool setRelativeMouseMode(bool value) {
- // Web is not supported due to Pointer Lock API integration complexity with Flutter's input system
- if (isWeb) {
- return false;
- }
-
- if (value) {
- if (!keyboardPerm() || isViewCamera()) {
- return false;
- }
-
- if (isDesktop && _imageWidgetSize == null) {
- // Desktop only: Ensure image widget size is available for proper center calculation.
- showToast(translate('rel-mouse-not-ready-tip'));
- return false;
- }
-
- if (!isSupported) {
- // Check server version support before enabling.
- showToast(translate('rel-mouse-not-supported-peer-tip'));
- return false;
- }
- }
-
- if (value) {
- try {
- if (isDesktop) {
- final requestId = ++_enableRequestId;
- if (isMacOS) {
- // macOS: Use native relative mouse mode with CGAssociateMouseAndMouseCursorPosition
- // This locks the cursor in place and provides raw delta via NSEvent monitor.
- _enableNativeRelativeMouseMode().then((success) {
- // Guard against stale callback: user may have toggled off relative mode
- // while the async enable was in progress.
- if (_enableRequestId != requestId) {
- return;
- }
- if (success) {
- _completeEnableRelativeMouseMode();
- }
- // Note: _enableNativeRelativeMouseMode already handles its own cleanup on failure
- });
- } else {
- // Windows/Linux: Use Flutter-based cursor recenter approach
- if (!getPointerInsideImage()) {
- _releaseCursorClip();
- }
-
- updatePointerLockCenter().then((_) => _recenterMouse()).then((_) {
- if (_enableRequestId != requestId) {
- return;
- }
- _completeEnableRelativeMouseMode();
- }).catchError((e) {
- if (_enableRequestId != requestId) {
- return;
- }
- debugPrint('[RelMouse] Platform setup failed: $e');
- _resetState();
- });
- }
- } else {
- // Mobile: enable immediately (no platform-specific setup needed)
- _completeEnableRelativeMouseMode();
- }
- } catch (e) {
- _disableWithCleanup();
- return false;
- }
- } else {
- // Best-effort marker for Rust rdev grab loop (ESC behavior).
- // Bypass keyboardPerm check to ensure Rust state is always synced,
- // even if permission was revoked while relative mode was active.
- _sendMouseMessageToSession(
- {
- 'relative_mouse_mode': '0',
- },
- disableRelativeOnError: false,
- bypassKeyboardPerm: true,
- );
-
- // Desktop only: cursor manipulation
- if (isDesktop) {
- if (isMacOS) {
- // macOS: Disable native relative mouse mode
- // This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse
- _disableNativeRelativeMouseMode();
- } else {
- _releaseCursorClip();
- }
- }
- enabled.value = false;
- _resetState();
- onDisabled?.call();
- }
-
- return true;
- }
-
- /// Called when platform setup completes successfully to finalize enabling relative mouse mode.
- void _completeEnableRelativeMouseMode() {
- enabled.value = true;
-
- // Show toast notification so user knows how to exit relative mouse mode (desktop only).
- if (isDesktop) {
- showToast(
- translate('rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
- alignment: Alignment.center);
- }
-
- // Best-effort marker for Rust rdev grab loop (ESC behavior) and peer/server state.
- // This uses a no-op delta so it does not move the remote cursor.
- // Intentionally fire-and-forget: we don't block enabling on this marker message.
- // Failures are logged but do not disable relative mouse mode.
- _sendMouseMessageToSession(
- {
- 'relative_mouse_mode': '1',
- 'type': 'move_relative',
- 'x': '0',
- 'y': '0',
- },
- disableRelativeOnError: false,
- ).catchError((e) {
- debugPrint('[RelMouse] Failed to send enable marker: $e');
- return false;
- });
- }
-
- // Flag to skip the first mouse move event after recenter (it's the recenter itself).
- bool _skipNextMouseMove = false;
-
- /// Handle relative mouse movement based on current local pointer position.
- /// Returns true if the event was handled in relative mode, false otherwise.
- bool handleRelativeMouseMove(Offset localPosition) {
- if (!enabled.value) return false;
-
- // macOS: Native mode handles delta via callback, skip Flutter-based handling.
- if (_isNativeRelativeMouseModeActive) {
- return true;
- }
-
- // Pointer move/hover implies we're inside the remote image.
- _ensurePointerLockEngaged();
-
- // Skip the mouse move event triggered by recenter operation itself.
- if (_skipNextMouseMove) {
- _skipNextMouseMove = false;
- _lastPointerLocalPos = localPosition;
- return true;
- }
-
- final lastLocal = _lastPointerLocalPos;
- _lastPointerLocalPos = localPosition;
-
- // Linux-specific: Proactive recenter check before processing delta.
- // On Linux, we don't have clip_cursor, so if the cursor moves too fast
- // it may escape the window before _recenterIfNearEdge can catch it.
- // Check now and recenter immediately if needed.
- if (isLinux) {
- _recenterIfNearEdgeLinux(localPosition);
- }
-
- // Calculate delta from last position (not from center).
- // This avoids issues with CGWarpMouseCursorPosition integer rounding.
- if (lastLocal != null) {
- final delta = localPosition - lastLocal;
- if (delta.dx != 0 || delta.dy != 0) {
- sendRelativeMouseMove(delta.dx, delta.dy);
- }
- }
-
- return true;
- }
-
- /// Linux-specific: More aggressive recenter check to prevent cursor escape.
- /// Called synchronously before processing mouse delta to ensure cursor stays within bounds.
- void _recenterIfNearEdgeLinux(Offset localPosition) {
- final size = _imageWidgetSize;
- if (size == null) return;
-
- final edgeThreshold = _calculateEdgeThreshold(size);
-
- final nearLeft = localPosition.dx < edgeThreshold;
- final nearRight = localPosition.dx > size.width - edgeThreshold;
- final nearTop = localPosition.dy < edgeThreshold;
- final nearBottom = localPosition.dy > size.height - edgeThreshold;
-
- if (nearLeft || nearRight || nearTop || nearBottom) {
- _recenterMouse();
- }
- }
-
- void sendRelativeMouseMove(double dx, double dy) {
- if (!isDesktop) return;
-
- final delta = _accumulator.add(dx, dy, maxDelta: kMaxRelativeMouseDelta);
- if (delta == null) return;
-
- // Buffer the delta for throttled sending.
- _pendingDeltaX += delta.x;
- _pendingDeltaY += delta.y;
-
- // Start or refresh the throttle timer.
- if (_throttleTimer == null || !_throttleTimer!.isActive) {
- _throttleTimer = Timer(_throttleInterval, () => _flushPendingDelta());
- }
- }
-
- Future _flushPendingDelta() async {
- if (!isDesktop) return;
- if (_pendingDeltaX == 0 && _pendingDeltaY == 0) return;
-
- final x = _pendingDeltaX;
- final y = _pendingDeltaY;
- _pendingDeltaX = 0;
- _pendingDeltaY = 0;
-
- final ok = await _sendMouseMessageToSession({
- 'type': 'move_relative',
- 'x': '$x',
- 'y': '$y',
- });
- if (!ok) return;
-
- // Only recenter when mouse is near the edge of the image widget.
- // This allows smooth mouse movement without constant recentering.
- _recenterIfNearEdge();
- }
-
- // Edge threshold parameters for recenter detection.
- // Threshold is calculated as: min(maxThreshold, min(width, height) * fraction)
- static const double _edgeThresholdFraction = 0.1; // 10% of smaller dimension
- static const double _edgeThresholdMax =
- 100.0; // Maximum threshold in logical pixels
- static const double _edgeThresholdMin =
- 20.0; // Minimum threshold for very small widgets
-
- // Linux-specific edge threshold parameters (more aggressive to prevent cursor escape).
- // On Linux, we don't have clip_cursor capability, so we need to recenter earlier
- // to prevent the cursor from escaping the window when moving fast.
- static const double _edgeThresholdFractionLinux =
- 0.25; // 25% of smaller dimension
- static const double _edgeThresholdMaxLinux =
- 200.0; // Larger maximum threshold for Linux
- static const double _edgeThresholdMinLinux =
- 50.0; // Larger minimum threshold for Linux
-
- /// Calculate dynamic edge threshold based on widget size.
- double _calculateEdgeThreshold(Size size) {
- final smallerDimension = math.min(size.width, size.height);
- if (isLinux) {
- // Use more aggressive thresholds on Linux to prevent cursor escape.
- final dynamicThreshold = smallerDimension * _edgeThresholdFractionLinux;
- return dynamicThreshold.clamp(
- _edgeThresholdMinLinux, _edgeThresholdMaxLinux);
- }
- final dynamicThreshold = smallerDimension * _edgeThresholdFraction;
- // Clamp between min and max thresholds
- return dynamicThreshold.clamp(_edgeThresholdMin, _edgeThresholdMax);
- }
-
- /// Recenter the cursor only if it's near the edge of the image widget.
- void _recenterIfNearEdge() {
- final lastPos = _lastPointerLocalPos;
- final size = _imageWidgetSize;
- if (lastPos == null || size == null) return;
-
- // Dynamic threshold based on widget size
- final edgeThreshold = _calculateEdgeThreshold(size);
-
- final nearLeft = lastPos.dx < edgeThreshold;
- final nearRight = lastPos.dx > size.width - edgeThreshold;
- final nearTop = lastPos.dy < edgeThreshold;
- final nearBottom = lastPos.dy > size.height - edgeThreshold;
-
- if (nearLeft || nearRight || nearTop || nearBottom) {
- _recenterMouse();
- }
- }
-
- /// Send mouse button event without position (for relative mouse mode).
- Future sendRelativeMouseButton(Map evt) async {
- if (!enabled.value) return;
- _ensurePointerLockEngaged();
-
- final rawType = evt['type'];
- final rawButtons = evt['buttons'];
- if (rawType is! String || rawButtons is! int) return;
-
- final type = _mouseEventTypeToPeer(rawType);
- if (type.isEmpty) return;
-
- final buttons = mouseButtonsToPeer(rawButtons);
- if (buttons.isEmpty) return;
-
- await _sendMouseMessageToSession({
- 'type': type,
- 'buttons': buttons,
- });
- }
-
- static String _mouseEventTypeToPeer(String type) {
- switch (type) {
- case 'mousedown':
- return kMouseEventTypeDown;
- case 'mouseup':
- return kMouseEventTypeUp;
- default:
- return '';
- }
- }
-
- Future _sendMouseMessageToSession(
- Map msg, {
- bool disableRelativeOnError = true,
- bool bypassKeyboardPerm = false,
- }) async {
- if (!bypassKeyboardPerm && !keyboardPerm()) return false;
- if (isViewCamera()) return false;
-
- try {
- await bind.sessionSendMouse(
- sessionId: sessionId,
- msg: json.encode(modify(msg)),
- );
- return true;
- } catch (e) {
- debugPrint('[RelMouse] Error sending mouse message: $e');
- if (disableRelativeOnError && enabled.value) {
- _disableWithCleanup();
- }
- return false;
- }
- }
-
- /// Retry parameters for cursor re-centering.
- static const int _recenterMaxRetries = 3;
- static const Duration _recenterRetryDelay = Duration(milliseconds: 100);
-
- /// Recenter the cursor to the pointer lock center.
- /// Fire-and-forget safe: prevents overlapping calls and catches errors internally.
- Future _recenterMouse() async {
- // Prevent overlapping recenter operations under high-frequency mouse moves.
- if (_recenterInProgress) return;
- _recenterInProgress = true;
-
- try {
- if (!enabled.value) return;
- if (!getPointerInsideImage()) return;
-
- final center = _pointerLockCenterScreen;
- if (center == null) {
- return;
- }
-
- for (int attempt = 0; attempt < _recenterMaxRetries; attempt++) {
- // Check preconditions before each attempt.
- if (!enabled.value || !getPointerInsideImage()) return;
-
- final ok = bind.mainSetCursorPosition(
- x: center.dx.toInt(),
- y: center.dy.toInt(),
- );
- if (ok) {
- // Skip the next mouse move event - it's triggered by the recenter itself.
- _skipNextMouseMove = true;
- return;
- }
-
- // Wait before retrying (except on the last attempt).
- if (attempt < _recenterMaxRetries - 1) {
- await Future.delayed(_recenterRetryDelay);
- }
- }
-
- // All attempts failed.
- _disableWithCleanup();
- showToast(translate('rel-mouse-lock-failed-tip'));
- } catch (e, st) {
- debugPrint('[RelMouse] Unexpected error in _recenterMouse: $e\n$st');
- } finally {
- _recenterInProgress = false;
- }
- }
-
- Future updatePointerLockCenter({Offset? localCenter}) async {
- if (!isDesktop) return;
-
- // Null safety check for kWindowId.
- if (kWindowId == null) {
- if (enabled.value) {
- _disableWithCleanup();
- }
- return;
- }
-
- try {
- final wc = WindowController.fromWindowId(kWindowId!);
- final frame = await wc.getFrame();
-
- if (frame.width <= 0 || frame.height <= 0) {
- if (enabled.value) {
- _disableWithCleanup();
- }
- return;
- }
-
- if (localCenter != null) {
- _pointerLockCenterLocal = localCenter;
- } else if (_imageWidgetSize != null) {
- _pointerLockCenterLocal = Offset(
- _imageWidgetSize!.width / 2,
- _imageWidgetSize!.height / 2,
- );
- } else {
- if (enabled.value) {
- _disableWithCleanup();
- }
- return;
- }
-
- // Calculate screen coordinates for OS cursor positioning.
- // Use PlatformDispatcher instead of deprecated ui.window.
- final view = ui.PlatformDispatcher.instance.views.firstOrNull;
- if (view == null) {
- debugPrint('[RelMouse] No view available for coordinate calculation');
- if (enabled.value) {
- _disableWithCleanup();
- }
- return;
- }
- final scale = view.devicePixelRatio;
-
- if (_pointerRegionTopLeftGlobal != null && scale > 0) {
- // On macOS, window frame and CGWarpMouseCursorPosition use points (not pixels).
- // On Windows, they use pixels.
- // Flutter's logical coordinates are in points on macOS.
- final centerInView =
- _pointerRegionTopLeftGlobal! + _pointerLockCenterLocal!;
-
- // Calculate client area offset (excluding title bar and borders)
- final clientPhysical = view.physicalSize;
-
- // macOS: Window frame and CGWarpMouseCursorPosition both use points (not pixels).
- // We convert clientPhysical (pixels) to points via `/ scale` to compute titleBarHeight,
- // which is the difference between the total window height and the Flutter view height.
- if (isMacOS) {
- final clientHeightPoints = clientPhysical.height / scale;
- final titleBarHeight = frame.height - clientHeightPoints;
-
- _pointerLockCenterScreen = Offset(
- frame.left + centerInView.dx,
- frame.top + titleBarHeight + centerInView.dy,
- );
- } else {
- // Windows/Linux: Use pixel coordinates. We estimate the client-area offset using
- // a heuristic based on the difference between frame size and client physical size.
- // This assumes symmetric horizontal borders (extraW / 2) and that the remaining
- // vertical space (extraH - borderBottom) is the title bar height.
- // Limitation: This heuristic may be inaccurate for maximized windows, custom window
- // decorations, or when the OS uses different border styles.
- // TODO: Replace this heuristic with platform API calls (e.g., GetClientRect on Windows)
- // if precise client-area offsets are required.
- final extraW = frame.width - clientPhysical.width;
- final extraH = frame.height - clientPhysical.height;
- final borderX = extraW > 0 ? extraW / 2 : 0.0;
- final borderBottom = borderX;
- final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0;
- final clientTopLeftScreen =
- Offset(frame.left + borderX, frame.top + borderTop);
-
- // Calculate tentative center, then validate it's within frame bounds.
- // This guards against heuristic inaccuracies (e.g., maximized windows).
- final tentativeCenter = Offset(
- clientTopLeftScreen.dx + centerInView.dx * scale,
- clientTopLeftScreen.dy + centerInView.dy * scale,
- );
- final withinFrame = tentativeCenter.dx >= frame.left &&
- tentativeCenter.dx <= frame.left + frame.width &&
- tentativeCenter.dy >= frame.top &&
- tentativeCenter.dy <= frame.top + frame.height;
- _pointerLockCenterScreen = withinFrame
- ? tentativeCenter
- : Offset(
- frame.left + frame.width / 2, frame.top + frame.height / 2);
- }
- } else {
- _pointerLockCenterScreen = Offset(
- frame.left + frame.width / 2,
- frame.top + frame.height / 2,
- );
- }
-
- if (enabled.value && isWindows && getPointerInsideImage()) {
- _applyCursorClipForFrame(frame);
- } else if (enabled.value && isWindows && _cursorClipApplied) {
- // Only release if we actually have a clip applied to avoid redundant FFI calls.
- _releaseCursorClip();
- }
- // macOS: no clip_cursor (CGAssociateMouseAndMouseCursorPosition stops mouse events)
- // Instead, we use recenter method like other platforms.
- } catch (e) {
- if (enabled.value) {
- _disableWithCleanup();
- } else {
- _pointerLockCenterLocal = null;
- _pointerLockCenterScreen = null;
- }
- }
- }
-
- void _ensurePointerLockEngaged() {
- if (!enabled.value) return;
- if (!isDesktop) return;
-
- setPointerInsideImage(true);
-
- final needsCenter =
- _pointerLockCenterLocal == null || _pointerLockCenterScreen == null;
- // Windows only: cursor clip
- final needsClip = isWindows && !_cursorClipApplied;
- if (needsCenter || needsClip) {
- updatePointerLockCenter()
- .then((_) => _recenterMouse())
- .catchError((Object e, StackTrace st) {
- debugPrint('[RelMouse] updatePointerLockCenter failed: $e\n$st');
- _disableWithCleanup();
- });
- }
- }
-
- void _applyCursorClipForFrame(Rect frame) {
- if (!isWindows) return;
-
- // Use PlatformDispatcher to get the device pixel ratio for proper scaling.
- final view = ui.PlatformDispatcher.instance.views.firstOrNull;
- final scale = view?.devicePixelRatio ?? 1.0;
-
- // Get the Flutter view's physical size (client area in pixels).
- final clientPhysical = view?.physicalSize ?? ui.Size.zero;
-
- // Calculate the non-client area (OS window title bar, borders).
- // frame includes the entire window (title bar + borders + client area).
- final extraW = frame.width - clientPhysical.width;
- final extraH = frame.height - clientPhysical.height;
-
- // Assume symmetric horizontal borders.
- final borderX = extraW > 0 ? extraW / 2 : 0.0;
- // Bottom border is typically the same as side borders.
- final borderBottom = borderX;
- // OS window title bar height is the remaining vertical non-client space.
- final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0;
-
- // Calculate client area top-left in screen coordinates.
- final clientTopLeftScreen =
- Offset(frame.left + borderX, frame.top + borderTop);
-
- int left, top, right, bottom;
-
- // If we have precise image widget info, clip to the remote image area.
- // This excludes the Flutter app's internal title bar and toolbar.
- if (_pointerRegionTopLeftGlobal != null &&
- _imageWidgetSize != null &&
- scale > 0) {
- // _pointerRegionTopLeftGlobal is in Flutter logical coordinates (relative to client area).
- // Convert to screen physical coordinates.
- left = (clientTopLeftScreen.dx + _pointerRegionTopLeftGlobal!.dx * scale)
- .toInt();
- top = (clientTopLeftScreen.dy + _pointerRegionTopLeftGlobal!.dy * scale)
- .toInt();
- right = (left + _imageWidgetSize!.width * scale).toInt();
- bottom = (top + _imageWidgetSize!.height * scale).toInt();
- } else {
- // Fallback: clip to client area (excluding OS window decorations).
- left = clientTopLeftScreen.dx.toInt();
- top = clientTopLeftScreen.dy.toInt();
- right = (frame.left + frame.width - borderX).toInt();
- bottom = (frame.top + frame.height - borderBottom).toInt();
- }
-
- _cursorClipApplied = bind.mainClipCursor(
- left: left,
- top: top,
- right: right,
- bottom: bottom,
- enable: true,
- );
- }
-
- void _releaseCursorClip() {
- if (!_cursorClipApplied) return;
- _cursorClipApplied = false;
- if (!isWindows) return;
-
- bind.mainClipCursor(
- left: 0,
- top: 0,
- right: 0,
- bottom: 0,
- enable: false,
- );
- }
-
- void _resetState() {
- // Flush any pending delta before clearing state.
- // This ensures the last buffered movement is sent before values are zeroed.
- // Fire-and-forget: we don't wait for the async send to complete.
- if (_throttleTimer != null || _pendingDeltaX != 0 || _pendingDeltaY != 0) {
- _throttleTimer?.cancel();
- _throttleTimer = null;
- if (_pendingDeltaX != 0 || _pendingDeltaY != 0) {
- final x = _pendingDeltaX;
- final y = _pendingDeltaY;
- _pendingDeltaX = 0;
- _pendingDeltaY = 0;
- // Send without awaiting; skip recenter since we're disabling.
- _sendMouseMessageToSession({
- 'type': 'move_relative',
- 'x': '$x',
- 'y': '$y',
- }, disableRelativeOnError: false);
- }
- }
- _accumulator.reset();
- _pointerLockCenterLocal = null;
- _pointerLockCenterScreen = null;
- _pointerRegionTopLeftGlobal = null;
- _lastPointerLocalPos = null;
- _skipNextMouseMove = false;
- setPointerInsideImage(false);
- _cursorClipApplied = false;
- _exitShortcutKeyDown = false;
- }
-
- /// Core cleanup logic shared by [_disableWithCleanup] and [dispose].
- /// Sends disable message to Rust, releases platform resources, and resets state.
- void _performCleanupCore() {
- // Best-effort marker for Rust rdev grab loop (ESC behavior).
- // Bypass keyboardPerm check to ensure Rust state is always synced.
- _sendMouseMessageToSession(
- {
- 'relative_mouse_mode': '0',
- },
- disableRelativeOnError: false,
- bypassKeyboardPerm: true,
- );
-
- // macOS: Disable native relative mouse mode
- // This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse
- if (isMacOS) {
- _disableNativeRelativeMouseMode();
- } else {
- _releaseCursorClip();
- }
-
- _resetState();
- }
-
- void _disableWithCleanup() {
- _performCleanupCore();
- enabled.value = false;
- onDisabled?.call();
- }
-
- bool _disposed = false;
-
- void dispose() {
- if (_disposed) return;
- _disposed = true;
-
- _performCleanupCore();
- _imageWidgetSize = null;
- _lastToggle = null;
- // Set enabled to false BEFORE calling onDisabled, consistent with _disableWithCleanup().
- enabled.value = false;
- // Trigger callback before clearing it, so external cleanup can run.
- onDisabled?.call();
- onDisabled = null;
- }
-}
diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart
index 40c94fcf5..c3e6fab71 100644
--- a/flutter/lib/models/server_model.dart
+++ b/flutter/lib/models/server_model.dart
@@ -8,6 +8,7 @@ import 'package:flutter_hbb/mobile/pages/settings_page.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import '../common.dart';
@@ -50,8 +51,6 @@ class ServerModel with ChangeNotifier {
Timer? cmHiddenTimer;
- final _wakelockKey = UniqueKey();
-
bool get isStart => _isStart;
bool get mediaOk => _mediaOk;
@@ -298,7 +297,7 @@ class ServerModel with ChangeNotifier {
}
toggleAudio() async {
- if (clients.any((c) => !c.disconnected)) {
+ if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
@@ -316,7 +315,7 @@ class ServerModel with ChangeNotifier {
}
toggleFile() async {
- if (clients.any((c) => !c.disconnected)) {
+ if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_fileOk &&
@@ -345,7 +344,7 @@ class ServerModel with ChangeNotifier {
}
toggleInput() async {
- if (clients.any((c) => !c.disconnected)) {
+ if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (_inputOk) {
@@ -467,8 +466,21 @@ class ServerModel with ChangeNotifier {
await parent.target?.invokeMethod("stop_service");
await bind.mainStopService();
notifyListeners();
- // for androidUpdatekeepScreenOn only
- WakelockManager.disable(_wakelockKey);
+ if (!isLinux) {
+ // current linux is not supported
+ WakelockPlus.disable();
+ }
+ }
+
+ Future setPermanentPassword(String newPW) async {
+ await bind.mainSetPermanentPassword(password: newPW);
+ await Future.delayed(Duration(milliseconds: 500));
+ final pw = await bind.mainGetPermanentPassword();
+ if (newPW == pw) {
+ return true;
+ } else {
+ return false;
+ }
}
fetchID() async {
@@ -549,19 +561,10 @@ class ServerModel with ChangeNotifier {
if (index < 0) {
_clients.add(client);
} else {
- if (_clients[index].authorized) {
- _clients[index].privacyMode = client.privacyMode;
- notifyListeners();
- return;
- }
_clients[index].authorized = true;
- _clients[index].privacyMode = client.privacyMode;
}
} else {
- final index = _clients.indexWhere((c) => c.id == client.id);
- if (index >= 0) {
- _clients[index].privacyMode = client.privacyMode;
- notifyListeners();
+ if (_clients.any((c) => c.id == client.id)) {
return;
}
_clients.add(client);
@@ -610,12 +613,12 @@ class ServerModel with ChangeNotifier {
void showLoginDialog(Client client) {
showClientDialog(
client,
- client.isFileTransfer
- ? "Transfer file"
+ client.isFileTransfer
+ ? "Transfer file"
: client.isViewCamera
? "View camera"
- : client.isTerminal
- ? "Terminal"
+ : client.isTerminal
+ ? "Terminal"
: "Share screen",
'Do you accept?',
'android_new_connection_tip',
@@ -794,10 +797,12 @@ class ServerModel with ChangeNotifier {
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
(keepScreenOn == KeepScreenOn.duringControlled &&
_clients.map((e) => !e.disconnected).isNotEmpty);
- if (on) {
- WakelockManager.enable(_wakelockKey, isServer: true);
- } else {
- WakelockManager.disable(_wakelockKey);
+ if (on != await WakelockPlus.enabled) {
+ if (on) {
+ WakelockPlus.enable();
+ } else {
+ WakelockPlus.disable();
+ }
}
}
}
@@ -818,7 +823,6 @@ class Client {
bool isTerminal = false;
String portForward = "";
String name = "";
- String avatar = "";
String peerId = ""; // peer user's id,show at app
bool keyboard = false;
bool clipboard = false;
@@ -827,7 +831,6 @@ class Client {
bool restart = false;
bool recording = false;
bool blockInput = false;
- bool privacyMode = false;
bool disconnected = false;
bool fromSwitch = false;
bool inVoiceCall = false;
@@ -847,7 +850,6 @@ class Client {
isTerminal = json['is_terminal'] ?? false;
portForward = json['port_forward'];
name = json['name'];
- avatar = json['avatar'] ?? '';
peerId = json['peer_id'];
keyboard = json['keyboard'];
clipboard = json['clipboard'];
@@ -856,7 +858,6 @@ class Client {
restart = json['restart'];
recording = json['recording'];
blockInput = json['block_input'];
- privacyMode = json['privacy_mode'] ?? privacyMode;
disconnected = json['disconnected'];
fromSwitch = json['from_switch'];
inVoiceCall = json['in_voice_call'];
@@ -872,7 +873,6 @@ class Client {
data['is_terminal'] = isTerminal;
data['port_forward'] = portForward;
data['name'] = name;
- data['avatar'] = avatar;
data['peer_id'] = peerId;
data['keyboard'] = keyboard;
data['clipboard'] = clipboard;
@@ -881,7 +881,6 @@ class Client {
data['restart'] = restart;
data['recording'] = recording;
data['block_input'] = blockInput;
- data['privacy_mode'] = privacyMode;
data['disconnected'] = disconnected;
data['from_switch'] = fromSwitch;
data['in_voice_call'] = inVoiceCall;
diff --git a/flutter/lib/models/state_model.dart b/flutter/lib/models/state_model.dart
index 77195d662..2e1b516df 100644
--- a/flutter/lib/models/state_model.dart
+++ b/flutter/lib/models/state_model.dart
@@ -1,4 +1,5 @@
import 'package:desktop_multi_window/desktop_multi_window.dart';
+import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:get/get.dart';
@@ -29,11 +30,6 @@ class StateGlobal {
String _inputSource = '';
- // Track relative mouse mode state for each peer connection.
- // Key: peerId, Value: true if relative mouse mode is active.
- // Note: This is session-only runtime state, NOT persisted to config.
- final RxMap