diff --git a/AGENTS.md b/AGENTS.md index 68526d66d..e36c65fab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,47 +1,18 @@ -# RustDesk Guide +# RustDesk Guide -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Development Commands - -### Build Commands -- `cargo run` - Build and run the desktop application (requires libsciter library) -- `python3 build.py --flutter` - Build Flutter version (desktop) -- `python3 build.py --flutter --release` - Build Flutter version in release mode -- `python3 build.py --hwcodec` - Build with hardware codec support -- `python3 build.py --vram` - Build with VRAM feature (Windows only) -- `cargo build --release` - Build Rust binary in release mode -- `cargo build --features hwcodec` - Build with specific features - -### Flutter Mobile Commands -- `cd flutter && flutter build android` - Build Android APK -- `cd flutter && flutter build ios` - Build iOS app -- `cd flutter && flutter run` - Run Flutter app in development mode -- `cd flutter && flutter test` - Run Flutter tests - -### Testing -- `cargo test` - Run Rust tests -- `cd flutter && flutter test` - Run Flutter tests - -### Platform-Specific Build Scripts -- `flutter/build_android.sh` - Android build script -- `flutter/build_ios.sh` - iOS build script -- `flutter/build_fdroid.sh` - F-Droid build script - -## Project Architecture +## Project Layout ### Directory Structure -- **`src/`** - Main Rust application code - - `src/ui/` - Legacy Sciter UI (deprecated, use Flutter instead) - - `src/server/` - Audio/clipboard/input/video services and network connections - - `src/client.rs` - Peer connection handling - - `src/platform/` - Platform-specific code -- **`flutter/`** - Flutter UI code for desktop and mobile -- **`libs/`** - Core libraries - - `libs/hbb_common/` - Video codec, config, network wrapper, protobuf, file transfer utilities - - `libs/scrap/` - Screen capture functionality - - `libs/enigo/` - Platform-specific keyboard/mouse control - - `libs/clipboard/` - Cross-platform clipboard implementation +* `src/` Rust app +* `src/server/` audio / clipboard / input / video / network +* `src/platform/` platform-specific code +* `src/ui/` legacy Sciter UI (deprecated) +* `flutter/` current UI +* `libs/hbb_common/` config / proto / shared utils +* `libs/scrap/` screen capture +* `libs/enigo/` input control +* `libs/clipboard/` clipboard +* `libs/hbb_common/src/config.rs` all options ### Key Components - **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server @@ -57,50 +28,35 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Mobile: `flutter/lib/mobile/` - Shared: `flutter/lib/common/` and `flutter/lib/models/` -## Important Build Notes - -### Dependencies -- Requires vcpkg for C++ dependencies: `libvpx`, `libyuv`, `opus`, `aom` -- Set `VCPKG_ROOT` environment variable -- Download appropriate Sciter library for legacy UI support - -### Ignore Patterns -When working with files, ignore these directories: -- `target/` - Rust build artifacts -- `flutter/build/` - Flutter build output -- `flutter/.dart_tool/` - Flutter tooling files - -### Cross-Platform Considerations -- Windows builds require additional DLLs and virtual display drivers -- macOS builds need proper signing and notarization for distribution -- Linux builds support multiple package formats (deb, rpm, AppImage) -- Mobile builds require platform-specific toolchains (Android SDK, Xcode) - -### Feature Flags -- `hwcodec` - Hardware video encoding/decoding -- `vram` - VRAM optimization (Windows only) -- `flutter` - Enable Flutter UI -- `unix-file-copy-paste` - Unix file clipboard support -- `screencapturekit` - macOS ScreenCaptureKit (macOS only) - -### Config -All configurations or options are under `libs/hbb_common/src/config.rs` file, 4 types: -- Settings -- Local -- Display -- Built-in - ## Rust Rules -- In Rust code, do not introduce `unwrap()` or `expect()`. -- Allowed exceptions: -- Tests may use `unwrap()` or `expect()` when it keeps the test focused and readable. -- Lock acquisition may use `unwrap()` only when the locking API makes that the practical option and the failure mode is poison handling rather than normal control flow. -- Outside those exceptions, propagate errors, handle them explicitly, or use safer fallbacks instead of `unwrap()` and `expect()`. +* Avoid `unwrap()` / `expect()` in production code. +* Exceptions: + + * tests; + * lock acquisition where failure means poisoning, not normal control flow. +* Otherwise prefer `Result` + `?` or explicit handling. +* Do not ignore errors silently. +* Avoid unnecessary `.clone()`. +* Prefer borrowing when practical. +* Do not add dependencies unless needed. +* Keep code simple and idiomatic. + +## Tokio Rules + +* Assume a Tokio runtime already exists. +* Never create nested runtimes. +* Never call `Runtime::block_on()` inside Tokio / async code. +* Do not hide runtime creation inside helpers or libraries. +* Do not hold locks across `.await`. +* Prefer `.await`, `tokio::spawn`, channels. +* Use `spawn_blocking` or dedicated threads for blocking work. +* Do not use `std::thread::sleep()` in async code. ## Editing Hygiene -- Do not introduce formatting-only changes. -- Do not run repository-wide formatters or reflow unrelated code unless the - user explicitly asks for formatting. -- Keep diffs limited to semantic changes required for the task. +* Change only what is required. +* Prefer the smallest valid diff. +* Do not refactor unrelated code. +* Do not make formatting-only changes. +* Keep naming/style consistent with nearby code. diff --git a/build.py b/build.py index ce9a09ef6..5c53e4fc8 100755 --- a/build.py +++ b/build.py @@ -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('mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..') + system2(f'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/docs/CODE_OF_CONDUCT-FR.md b/docs/CODE_OF_CONDUCT-FR.md new file mode 100644 index 000000000..dca61e0aa --- /dev/null +++ b/docs/CODE_OF_CONDUCT-FR.md @@ -0,0 +1,143 @@ + +# 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 new file mode 100644 index 000000000..6f800de7d --- /dev/null +++ b/docs/CONTRIBUTING-FR.md @@ -0,0 +1,55 @@ + +# 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-FR.md b/docs/README-FR.md index c2e25886d..345e53b58 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/Osx : vcpkg install libvpx libyuv opus aom + - Linux/macOS : vcpkg install libvpx libyuv opus aom -- Exécuter `cargo run` +- Exécutez `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 -Exécution du cargo +cargo run ``` ## Comment construire avec Docker diff --git a/docs/SECURITY-FR.md b/docs/SECURITY-FR.md new file mode 100644 index 000000000..1cf2c6167 --- /dev/null +++ b/docs/SECURITY-FR.md @@ -0,0 +1,16 @@ + +# 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/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 db222dc84..05742d7fd 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,7 +62,13 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: return false } } - audioRecorder = builder.build() + val recorder = try { + builder.build() + } catch (e: Exception) { + Log.e(logTag, "createAudioRecorder failed", e) + return false + } + audioRecorder = recorder Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize") return true } diff --git a/flutter/lib/common/widgets/remote_input.dart b/flutter/lib/common/widgets/remote_input.dart index 5871033db..9515ca759 100644 --- a/flutter/lib/common/widgets/remote_input.dart +++ b/flutter/lib/common/widgets/remote_input.dart @@ -532,7 +532,9 @@ class _RawTouchGestureDetectorRegionState // Official TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), (instance) { + () => TapGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onTapDown = onTapDown ..onTapUp = onTapUp @@ -540,14 +542,18 @@ class _RawTouchGestureDetectorRegionState }), DoubleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => DoubleTapGestureRecognizer(), (instance) { + () => DoubleTapGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onDoubleTapDown = onDoubleTapDown ..onDoubleTap = onDoubleTap; }), LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer(), (instance) { + () => LongPressGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onLongPressDown = onLongPressDown ..onLongPressUp = onLongPressUp @@ -557,7 +563,9 @@ class _RawTouchGestureDetectorRegionState // Customized HoldTapMoveGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => HoldTapMoveGestureRecognizer(), + () => HoldTapMoveGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) => instance ..onHoldDragStart = onHoldDragStart ..onHoldDragUpdate = onHoldDragUpdate @@ -565,14 +573,18 @@ class _RawTouchGestureDetectorRegionState ..onHoldDragEnd = onHoldDragEnd), DoubleFinerTapGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => DoubleFinerTapGestureRecognizer(), (instance) { + () => DoubleFinerTapGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onDoubleFinerTap = onDoubleFinerTap ..onDoubleFinerTapDown = onDoubleFinerTapDown; }), CustomTouchGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => CustomTouchGestureRecognizer(), (instance) { + () => CustomTouchGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance.onOneFingerPanStart = (DragStartDetails d) => onOneFingerPanStart(context, d); instance diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 1a6160324..2e7247d95 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -759,9 +759,18 @@ 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 = !ffi.ffiModel.viewOnly; + final enabled = + !ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty); return TToggleMenu( value: privacyModeState.isNotEmpty, onChanged: enabled @@ -810,18 +819,29 @@ List toolbarPrivacyMode( }) ]; } else { - return privacyModeImpls.map((e) { + final visibleImpls = hasPrivacyModePermission + ? privacyModeImpls + : privacyModeImpls.where((e) { + final implKey = (e as List)[0] as String; + return privacyModeState.value == implKey; + }).toList(); + return visibleImpls.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: (value) { - if (value == null) return; - togglePrivacyModeTime = DateTime.now(); - bind.sessionTogglePrivacyMode( - sessionId: sessionId, implKey: implKey, on: value); - }); + onChanged: enabled + ? (value) { + if (value == null) return; + if (value && !hasPrivacyModePermission) return; + togglePrivacyModeTime = DateTime.now(); + bind.sessionTogglePrivacyMode( + sessionId: sessionId, implKey: implKey, on: value); + } + : null); }).toList(); } } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 51c08cf33..832b96d24 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -114,6 +114,9 @@ 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"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index d118b6793..2841c1d27 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -1062,6 +1062,10 @@ 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), diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 7d48452a8..8bd7df08b 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -610,19 +610,24 @@ 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) { + Function(bool)? onTap, String tooltipText, + {required bool canModify}) { return Tooltip( message: "$tooltipText: ${enabled ? "ON" : "OFF"}", waitDuration: Duration.zero, child: Container( decoration: BoxDecoration( - color: enabled ? MyTheme.accent : Colors.grey[700], + color: enabled + ? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6)) + : Colors.grey[700], borderRadius: BorderRadius.circular(10.0), ), padding: EdgeInsets.all(8.0), child: InkWell( - onTap: () => - checkClickTime(widget.client.id, () => onTap?.call(!enabled)), + onTap: canModify + ? () => + checkClickTime(widget.client.id, () => onTap?.call(!enabled)) + : null, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ @@ -643,6 +648,9 @@ 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, @@ -689,6 +697,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable audio'), + canModify: canModifyPermission, ), buildPermissionIcon( client.recording, @@ -703,6 +712,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable recording session'), + canModify: canModifyPermission, ), ] : [ @@ -719,6 +729,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable keyboard/mouse'), + canModify: canModifyPermission, ), buildPermissionIcon( client.clipboard, @@ -733,6 +744,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable clipboard'), + canModify: canModifyPermission, ), buildPermissionIcon( client.audio, @@ -747,6 +759,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable audio'), + canModify: canModifyPermission, ), buildPermissionIcon( client.file, @@ -761,6 +774,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable file copy and paste'), + canModify: canModifyPermission, ), buildPermissionIcon( client.restart, @@ -775,6 +789,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable remote restart'), + canModify: canModifyPermission, ), buildPermissionIcon( client.recording, @@ -789,6 +804,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { }); }, translate('Enable recording session'), + canModify: canModifyPermission, ), // only windows support block input if (isWindows) @@ -805,6 +821,23 @@ 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/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index ec05c987f..5da253e80 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -996,10 +996,10 @@ class _DisplayMenuState extends State<_DisplayMenu> { toggles(), ]; // privacy mode + final privacyModeState = PrivacyModeState.find(id); if (ffi.connType == ConnType.defaultConn && - ffiModel.keyboard && - pi.features.privacyMode) { - final privacyModeState = PrivacyModeState.find(id); + (pi.features.privacyMode || privacyModeState.isNotEmpty) && + (ffiModel.keyboard || privacyModeState.isNotEmpty)) { final privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, ffi); if (privacyModeList.length == 1) { diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 9102d163c..74a5af45c 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -426,12 +426,10 @@ class _RemotePageState extends State with WidgetsBindingObserver { } return Container( color: MyTheme.canvasColor, - child: inputModel.isPhysicalMouse.value - ? getBodyForMobile() - : RawTouchGestureDetectorRegion( - child: getBodyForMobile(), - ffi: gFFI, - ), + child: RawTouchGestureDetectorRegion( + child: getBodyForMobile(), + ffi: gFFI, + ), ); }), ), @@ -1185,7 +1183,8 @@ void showOptions( List privacyModeList = []; // privacy mode final privacyModeState = PrivacyModeState.find(id); - if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) { + if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) || + privacyModeState.isNotEmpty) { 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 2c8b0f2d6..cd3f97a53 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -583,9 +583,16 @@ 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 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: [ @@ -608,13 +615,21 @@ class _PermissionCheckerState extends State { 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), + PermissionRow( + translate("Input Control"), + serverModel.inputOk, + serverModel.toggleInput, + ), + PermissionRow( + translate("Transfer file"), + serverModel.fileOk, + serverModel.toggleFile, + enabled: !permissionChangeLocked, + ), hasAudioPermission ? PermissionRow(translate("Audio Capture"), serverModel.audioOk, - serverModel.toggleAudio) + serverModel.toggleAudio, + enabled: !permissionChangeLocked) : Row(children: [ Icon(Icons.info_outline).marginOnly(right: 15), Expanded( @@ -623,19 +638,25 @@ class _PermissionCheckerState extends State { style: const TextStyle(color: MyTheme.darkGray), )) ]), - PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk, - serverModel.toggleClipboard), + PermissionRow( + translate("Enable clipboard"), + serverModel.clipboardOk, + serverModel.toggleClipboard, + enabled: !permissionChangeLocked, + ), ])); } } class PermissionRow extends StatelessWidget { - const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key}) + const PermissionRow(this.name, this.isOk, this.onPressed, + {Key? key, this.enabled = true}) : super(key: key); final String name; final bool isOk; final VoidCallback onPressed; + final bool enabled; @override Widget build(BuildContext context) { @@ -644,9 +665,11 @@ class PermissionRow extends StatelessWidget { contentPadding: EdgeInsets.all(0), title: Text(name), value: isOk, - onChanged: (bool value) { - onPressed(); - }); + onChanged: enabled + ? (bool value) { + onPressed(); + } + : null); } } diff --git a/flutter/lib/mobile/pages/view_camera_page.dart b/flutter/lib/mobile/pages/view_camera_page.dart index 0898125c4..08c8cda1a 100644 --- a/flutter/lib/mobile/pages/view_camera_page.dart +++ b/flutter/lib/mobile/pages/view_camera_page.dart @@ -259,13 +259,11 @@ class _ViewCameraPageState extends State } return Container( color: MyTheme.canvasColor, - child: inputModel.isPhysicalMouse.value - ? getBodyForMobile() - : RawTouchGestureDetectorRegion( - child: getBodyForMobile(), - ffi: gFFI, - isCamera: true, - ), + child: RawTouchGestureDetectorRegion( + child: getBodyForMobile(), + ffi: gFFI, + isCamera: true, + ), ); }), ), diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 675a95e42..6fdffd796 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -2,6 +2,7 @@ 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,12 +16,13 @@ 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 } +enum MouseButtons { left, right, wheel, back, forward } const _kMouseEventDown = 'mousedown'; const _kMouseEventUp = 'mouseup'; @@ -157,6 +159,8 @@ extension ToString on MouseButtons { return 'wheel'; case MouseButtons.back: return 'back'; + case MouseButtons.forward: + return 'forward'; } } } @@ -327,6 +331,80 @@ 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 (!Platform.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 = ''; @@ -412,6 +490,7 @@ class InputModel { bool get isRelativeMouseModeSupported => _relativeMouse.isSupported; InputModel(this.parent) { + initSideButtonChannel(); sessionId = parent.target!.sessionId; _relativeMouse = RelativeMouseModel( sessionId: sessionId, @@ -620,6 +699,38 @@ 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; @@ -674,6 +785,27 @@ 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); @@ -717,6 +849,8 @@ class InputModel { 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) { @@ -754,6 +888,21 @@ 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) { @@ -966,13 +1115,20 @@ 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 bind.sessionSendMouse( - sessionId: sessionId, - msg: json.encode(modify({'type': type, 'buttons': button.value}))); + await _sendMouseUnchecked(type, button); } void enterOrLeave(bool enter) { @@ -982,6 +1138,13 @@ class InputModel { _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(); @@ -1332,6 +1495,16 @@ class InputModel { 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; @@ -1344,6 +1517,9 @@ class InputModel { // 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; } @@ -1353,6 +1529,10 @@ class InputModel { } 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; } diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart new file mode 100644 index 000000000..e65c32790 --- /dev/null +++ b/flutter/lib/models/input_modifier_utils.dart @@ -0,0 +1,38 @@ +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 4533f11fa..e94834a2b 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -3932,6 +3932,7 @@ class FFI { 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/server_model.dart b/flutter/lib/models/server_model.dart index 78e334d4f..40c94fcf5 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier { } toggleAudio() async { - if (clients.isNotEmpty) { + if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) { @@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier { } toggleFile() async { - if (clients.isNotEmpty) { + if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } if (!_fileOk && @@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier { } toggleInput() async { - if (clients.isNotEmpty) { + if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } if (_inputOk) { @@ -549,10 +549,19 @@ 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 { - if (_clients.any((c) => c.id == client.id)) { + final index = _clients.indexWhere((c) => c.id == client.id); + if (index >= 0) { + _clients[index].privacyMode = client.privacyMode; + notifyListeners(); return; } _clients.add(client); @@ -818,6 +827,7 @@ class Client { bool restart = false; bool recording = false; bool blockInput = false; + bool privacyMode = false; bool disconnected = false; bool fromSwitch = false; bool inVoiceCall = false; @@ -846,6 +856,7 @@ 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']; @@ -870,6 +881,7 @@ 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/web/bridge.dart b/flutter/lib/web/bridge.dart index a3d93f88e..54e6a9a9b 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1729,7 +1729,7 @@ class RustdeskImpl { } String mainSupportedPrivacyModeImpls({dynamic hint}) { - throw UnimplementedError("mainSupportedPrivacyModeImpls"); + return '[]'; } String mainSupportedInputSource({dynamic hint}) { diff --git a/flutter/linux/my_application.cc b/flutter/linux/my_application.cc index a05bb7856..210adba96 100644 --- a/flutter/linux/my_application.cc +++ b/flutter/linux/my_application.cc @@ -29,6 +29,80 @@ void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view); extern bool gIsConnectionManager; +// --- Side mouse button support (back/forward) --- +// Flutter's Linux embedder doesn't deliver X11 button 8/9 events to Dart. +// We intercept them via GDK and forward through a dedicated platform channel. + +static const char* kSideButtonChannelName = "org.rustdesk.rustdesk/side_buttons"; + +static gboolean on_side_button_event(GtkWidget* widget, GdkEventButton* event, gpointer user_data) { + if (event->button != 8 && event->button != 9) { + return FALSE; + } + // Ignore GDK_2BUTTON_PRESS / GDK_3BUTTON_PRESS (double/triple-click synthetic + // events) - only handle real press and release. + if (event->type != GDK_BUTTON_PRESS && event->type != GDK_BUTTON_RELEASE) { + return FALSE; + } + + FlMethodChannel* channel = FL_METHOD_CHANNEL(user_data); + if (channel == NULL) return FALSE; + + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "button", + fl_value_new_string(event->button == 8 ? "back" : "forward")); + fl_value_set_string_take(args, "type", + fl_value_new_string(event->type == GDK_BUTTON_PRESS ? "down" : "up")); + + fl_method_channel_invoke_method(channel, "onSideMouseButton", args, + NULL, NULL, NULL); + + return TRUE; +} + +static FlMethodChannel* side_buttons_create_channel(FlEngine* engine) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + return fl_method_channel_new( + fl_engine_get_binary_messenger(engine), + kSideButtonChannelName, + FL_METHOD_CODEC(codec)); +} + +static void side_buttons_channel_destroy(gpointer data) { + g_object_unref(data); +} + +static void side_buttons_init_for_window(GtkWindow* window, FlMethodChannel* channel) { + // Guard against double-initialization (would leave dangling signal user_data). + if (g_object_get_data(G_OBJECT(window), "side-buttons-channel") != NULL) return; + + gtk_widget_add_events(GTK_WIDGET(window), + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK); + // Store channel on the window so it stays alive and is freed with the window. + g_object_set_data_full(G_OBJECT(window), "side-buttons-channel", + g_object_ref(channel), side_buttons_channel_destroy); + g_signal_connect(window, "button-press-event", + G_CALLBACK(on_side_button_event), channel); + g_signal_connect(window, "button-release-event", + G_CALLBACK(on_side_button_event), channel); +} + +static void on_subwindow_created(FlPluginRegistry* registry) { +#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) + wayland_shortcuts_inhibit_init_for_subwindow(registry); +#endif + // Set up side button forwarding for sub-windows. + if (registry == NULL || !FL_IS_VIEW(registry)) return; + FlView* view = FL_VIEW(registry); + GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view)); + if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) { + FlMethodChannel* channel = side_buttons_create_channel(fl_view_get_engine(view)); + if (channel == NULL) return; + side_buttons_init_for_window(GTK_WINDOW(toplevel), channel); + g_object_unref(channel); // window now owns a ref via g_object_set_data_full + } +} + GtkWidget *find_gl_area(GtkWidget *widget); // Implements GApplication::activate. @@ -96,12 +170,12 @@ static void my_application_activate(GApplication* application) { gtk_widget_show(GTK_WIDGET(window)); gtk_widget_show(GTK_WIDGET(view)); -#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) - // Register callback for sub-windows created by desktop_multi_window plugin - // Only sub-windows (remote windows) need keyboard shortcuts inhibition + // Register callback for sub-windows created by desktop_multi_window plugin. + // Handles both Wayland shortcuts inhibition (guarded inside) and side button + // forwarding. Safe to call on X11-only builds - the plugin just stores the + // callback pointer regardless of windowing system. desktop_multi_window_plugin_set_window_created_callback( - (WindowCreatedCallback)wayland_shortcuts_inhibit_init_for_subwindow); -#endif + (WindowCreatedCallback)on_subwindow_created); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); @@ -116,6 +190,11 @@ static void my_application_activate(GApplication* application) { self, nullptr); + // Forward side mouse button events (back/forward) to Dart on the main window. + FlMethodChannel* side_channel = side_buttons_create_channel(fl_view_get_engine(view)); + side_buttons_init_for_window(window, side_channel); + g_object_unref(side_channel); + gtk_widget_grab_focus(GTK_WIDGET(view)); } diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index eb6d76161..eddf5a19d 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -113,8 +113,8 @@ dependencies: dev_dependencies: icons_launcher: ^2.0.4 - #flutter_test: - #sdk: flutter + flutter_test: + sdk: flutter build_runner: ^2.4.6 freezed: ^2.4.2 flutter_lints: ^2.0.2 diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart new file mode 100644 index 000000000..2e1971753 --- /dev/null +++ b/flutter/test/input_modifier_utils_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/models/input_modifier_utils.dart'; + +void main() { + group('shouldReleaseStaleMobileShift', () { + test('does not release when cached shift is already false', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: false, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('releases one-shot mobile shift after a text key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('does not release manually toggled shift without tracked key down', + () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: false, + ), + isFalse, + ); + }); + + test('does not release when shift is still physically pressed', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: true, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('does not release on non-mobile platforms', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: false, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('releases on enter key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.enter, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('releases on arrow key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.arrowLeft, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('does not release on modifier events', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.shiftLeft, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('does not release on shiftRight modifier events', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.shiftRight, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + }); +} diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index e1856863e..95d1d1a5c 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -624,6 +624,7 @@ void CliprdrStream_Delete(CliprdrStream *instance) if (instance) { free(instance->iStream.lpVtbl); + instance->iStream.lpVtbl = NULL; free(instance); } } @@ -2160,7 +2161,7 @@ static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_fi return FALSE; /* add to name array */ - clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc(MAX_PATH * 2); + clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc((size_t)MAX_PATH * sizeof(WCHAR)); if (!clipboard->file_names[clipboard->nFiles]) return FALSE; diff --git a/libs/enigo/src/linux/xdo.rs b/libs/enigo/src/linux/xdo.rs index 26d090855..7796904f9 100644 --- a/libs/enigo/src/linux/xdo.rs +++ b/libs/enigo/src/linux/xdo.rs @@ -8,6 +8,7 @@ use crate::{Key, KeyboardControllable, MouseButton, MouseControllable}; use hbb_common::libc::c_int; +use hbb_common::x11::xlib::{Display, XCloseDisplay, XGetPointerMapping, XOpenDisplay}; use libxdo_sys::{self, xdo_t, CURRENTWINDOW}; use std::{borrow::Cow, ffi::CString}; @@ -32,6 +33,51 @@ fn mousebutton(button: MouseButton) -> c_int { } } +/// Minimum number of buttons the X11 core pointer must support. +/// Buttons 8 (Back) and 9 (Forward) are needed for mouse side buttons. +const MIN_POINTER_BUTTONS: usize = 9; + +/// Check that the X11 core pointer's button map includes at least 9 buttons +/// so that `XTestFakeButtonEvent` can simulate Back (8) and Forward (9). +/// +/// RustDesk's uinput "Mouse passthrough" device normally provides enough +/// buttons, but we log a warning if the map is too small so the issue is +/// diagnosable. `XSetPointerMapping` cannot extend the button count (its +/// length must match `XGetPointerMapping`), so we only diagnose here. +fn check_x11_button_map() { + // Skip on non-X11 sessions to avoid noisy "XOpenDisplay failed" warnings + // on pure Wayland or headless environments without $DISPLAY. + if std::env::var_os("DISPLAY").is_none() { + return; + } + + let display: *mut Display = unsafe { XOpenDisplay(std::ptr::null()) }; + if display.is_null() { + log::warn!("XOpenDisplay failed, cannot check button map"); + return; + } + + let mut current_map = [0u8; 32]; + let nbuttons = + unsafe { XGetPointerMapping(display, current_map.as_mut_ptr(), current_map.len() as i32) }; + unsafe { XCloseDisplay(display) }; + + if nbuttons < 0 { + log::warn!("XGetPointerMapping failed (returned {nbuttons})"); + return; + } + + let nbuttons = nbuttons as usize; + if nbuttons >= MIN_POINTER_BUTTONS { + log::info!("X11 pointer has {nbuttons} buttons, side buttons supported"); + } else { + log::warn!( + "X11 pointer has only {nbuttons} buttons (need {MIN_POINTER_BUTTONS}); \ + back/forward side buttons may not work until a device with more buttons is added" + ); + } +} + /// The main struct for handling the event emitting pub(super) struct EnigoXdo { xdo: *mut xdo_t, @@ -52,6 +98,7 @@ impl Default for EnigoXdo { log::warn!("Failed to create xdo context, xdo functions will be disabled"); } else { log::info!("xdo context created successfully"); + check_x11_button_map(); } Self { xdo, diff --git a/src/cli.rs b/src/cli.rs index f61bfe92f..2f3b3550f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,7 +25,13 @@ impl Session { pub fn new(id: &str, sender: mpsc::UnboundedSender) -> Self { let mut password = "".to_owned(); if PeerConfig::load(id).password.is_empty() { - password = rpassword::prompt_password("Enter password: ").unwrap(); + match rpassword::prompt_password("Enter password: ") { + Ok(p) => password = p, + Err(e) => { + log::error!("Failed to read password: {:?}", e); + password = "".to_owned(); + } + } } let session = Self { id: id.to_owned(), diff --git a/src/client.rs b/src/client.rs index 72652776a..321a49ee6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1745,6 +1745,9 @@ pub struct LoginConfigHandler { pub direct: Option, pub received: bool, switch_uuid: Option, + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + switch_back_allowed: bool, pub save_ab_password_to_recent: bool, // true: connected with ab password pub other_server: Option<(String, String, String)>, pub custom_fps: Arc>>, @@ -1861,6 +1864,11 @@ impl LoginConfigHandler { self.direct = None; self.received = false; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + self.switch_back_allowed = false; + } self.switch_uuid = switch_uuid; self.adapter_luid = adapter_luid; self.selected_windows_session_id = None; @@ -1874,6 +1882,23 @@ impl LoginConfigHandler { self.is_terminal_admin = is_terminal_admin; } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub fn allow_switch_back_once(&mut self) { + self.switch_back_allowed = true; + } + + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub fn consume_switch_back_permission(&mut self) -> bool { + if self.switch_back_allowed { + self.switch_back_allowed = false; + true + } else { + false + } + } + /// Check if the client should auto login. /// Return password if the client should auto login, otherwise return empty string. pub fn should_auto_login(&self) -> String { @@ -3377,6 +3402,36 @@ pub fn handle_login_error( } } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { + let Ok(mut conn) = crate::ipc::connect(1000, "").await else { + return false; + }; + let uuid = uuid.to_string(); + if conn + .send(&crate::ipc::Data::SwitchSidesUuid( + uuid.clone(), + id.to_owned(), + None, + )) + .await + .is_err() + { + return false; + } + match conn.next_timeout(1000).await { + Ok(Some(crate::ipc::Data::SwitchSidesUuid( + returned_uuid, + returned_id, + Some(true), + ))) => { + returned_uuid == uuid && returned_id == id + } + _ => false, + } +} + /// Handle hash message sent by peer. /// Hash will be used for login. /// @@ -3397,12 +3452,22 @@ pub async fn handle_hash( // Take care of password application order // switch_uuid - let uuid = lc.write().unwrap().switch_uuid.take(); - if let Some(uuid) = uuid { - if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { - send_switch_login_request(lc.clone(), peer, uuid).await; - lc.write().unwrap().password_source = Default::default(); - return; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let uuid = lc.write().unwrap().switch_uuid.take(); + if let Some(uuid) = uuid { + if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { + let id = lc.read().unwrap().id.clone(); + if !consume_local_switch_sides_uuid(&id, &uuid).await { + log::warn!("Ignored untrusted switch_uuid"); + } else { + lc.write().unwrap().allow_switch_back_once(); + send_switch_login_request(lc.clone(), peer, uuid).await; + lc.write().unwrap().password_source = Default::default(); + return; + } + } } } // last password diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 78d9a4e40..5eb7a273a 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1448,6 +1448,23 @@ impl Remote { if !self.handler.lc.read().unwrap().disable_clipboard.v { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(_mcb.clipboards, ClipboardSide::Client); + #[cfg(target_os = "ios")] + { + if let Some(cb) = _mcb + .clipboards + .iter() + .find(|c| c.format.enum_value() == Ok(ClipboardFormat::Text)) + { + let content = if cb.compress { + hbb_common::compress::decompress(&cb.content) + } else { + cb.content.to_vec() + }; + if let Ok(content) = String::from_utf8(content) { + self.handler.clipboard(content); + } + } + } #[cfg(target_os = "android")] crate::clipboard::handle_msg_multi_clipboards(_mcb); } @@ -1780,6 +1797,9 @@ impl Remote { Ok(Permission::BlockInput) => { self.handler.set_permission("block_input", p.enabled); } + Ok(Permission::PrivacyMode) => { + self.handler.set_permission("privacy_mode", p.enabled); + } _ => {} } } @@ -1903,9 +1923,23 @@ impl Remote { ); } } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchBack(_)) => { - #[cfg(feature = "flutter")] - self.handler.switch_back(&self.handler.get_id()); + let allow_switch_back = self + .handler + .lc + .write() + .unwrap() + .consume_switch_back_permission(); + if allow_switch_back { + self.handler.switch_back(&self.handler.get_id()); + } else { + log::warn!( + "Ignored unsolicited SwitchBack from {}", + self.handler.get_id() + ); + } } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 2d339f5c2..4b62b4fca 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -605,21 +605,30 @@ pub fn session_handle_flutter_raw_key_event( } } -// SyncReturn<()> is used to make sure enter() and leave() are executed in the sequence this function is called. -// // If the cursor jumps between remote page of two connections, leave view and enter view will be called. // session_enter_or_leave() will be called then. -// As rust is multi-thread, it is possible that enter() is called before leave(). -// This will cause the keyboard input to take no effect. +// As Rust is multi-threaded, enter() can be called before leave(). +// The Rust-side grab ownership state filters stale transitions. pub fn session_enter_or_leave(_session_id: SessionID, _enter: bool) -> SyncReturn<()> { #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(session) = sessions::get_session_by_session_id(&_session_id) { let keyboard_mode = session.get_keyboard_mode(); + // Use the full per-window UUID (not lc.session_id which is per-connection) + // so that two windows viewing the same peer get distinct grab owners. + let window_id = _session_id.as_u128(); if _enter { set_cur_session_id_(_session_id, &keyboard_mode); - session.enter(keyboard_mode); + crate::keyboard::client::change_grab_status( + crate::common::GrabState::Run, + &keyboard_mode, + window_id, + ); } else { - session.leave(keyboard_mode); + crate::keyboard::client::change_grab_status( + crate::common::GrabState::Wait, + &keyboard_mode, + window_id, + ); } } SyncReturn(()) @@ -963,6 +972,27 @@ pub fn main_show_option(_key: String) -> SyncReturn { } pub fn main_set_option(key: String, value: String) { + #[cfg(target_os = "android")] + { + let is_permission_option = key.eq(config::keys::OPTION_ENABLE_CLIPBOARD) + || key.eq(config::keys::OPTION_ENABLE_FILE_TRANSFER) + || key.eq(config::keys::OPTION_ENABLE_AUDIO); + let allow_perm_change_in_accept_window = config::option2bool( + config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ); + if is_permission_option + && !allow_perm_change_in_accept_window + && crate::ui_cm_interface::has_active_clients() + { + log::info!( + "blocked main_set_option by policy, key={}, value={}", + key, + value + ); + return; + } + } #[cfg(target_os = "android")] if key.eq(config::keys::OPTION_ENABLE_KEYBOARD) { crate::ui_cm_interface::switch_permission_all( @@ -1010,7 +1040,29 @@ pub fn main_get_options_sync() -> SyncReturn { } pub fn main_set_options(json: String) { - let map: HashMap = serde_json::from_str(&json).unwrap_or(HashMap::new()); + let mut map: HashMap = serde_json::from_str(&json).unwrap_or(HashMap::new()); + #[cfg(target_os = "android")] + { + let allow_perm_change_in_accept_window = config::option2bool( + config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ); + if !allow_perm_change_in_accept_window && crate::ui_cm_interface::has_active_clients() { + for key in [ + config::keys::OPTION_ENABLE_CLIPBOARD, + config::keys::OPTION_ENABLE_FILE_TRANSFER, + config::keys::OPTION_ENABLE_AUDIO, + ] { + if let Some(value) = map.remove(key) { + log::info!( + "blocked main_set_options item by policy, key={}, value={}", + key, + value + ); + } + } + } + } if !map.is_empty() { set_options(map) } @@ -2161,7 +2213,7 @@ pub fn cm_elevate_portable(conn_id: i32) { } pub fn cm_switch_back(conn_id: i32) { - #[cfg(not(any(target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] crate::ui_cm_interface::switch_back(conn_id); } diff --git a/src/ipc.rs b/src/ipc.rs index 099c24d34..82b52a60c 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -237,6 +237,7 @@ pub enum Data { restart: bool, recording: bool, block_input: bool, + privacy_mode: bool, from_switch: bool, }, ChatMessage { @@ -284,7 +285,14 @@ pub enum Data { Empty, Disconnected, DataPortableService(DataPortableService), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesRequest(String), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + SwitchSidesUuid(String, String, Option), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, UrlLink(String), VoiceCallIncoming, @@ -770,6 +778,8 @@ async fn handle(data: Data, stream: &mut Connection) { Data::TestRendezvousServer => { crate::test_rendezvous_server(); } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::SwitchSidesRequest(id) => { let uuid = uuid::Uuid::new_v4(); crate::server::insert_switch_sides_uuid(id, uuid.clone()); @@ -779,6 +789,19 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + Data::SwitchSidesUuid(uuid, id, None) => { + let allowed = uuid + .parse::() + .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) + .unwrap_or(false); + allow_err!( + stream + .send(&Data::SwitchSidesUuid(uuid, id, Some(allowed))) + .await + ); + } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await, diff --git a/src/keyboard.rs b/src/keyboard.rs index c5d4dfde8..b9cf4da2d 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -82,8 +82,67 @@ lazy_static::lazy_static! { pub mod client { use super::*; + /// Tracks grab ownership and serializes transitions across threads. + /// + /// Multiple Flutter isolates (one per session window) call + /// `change_grab_status(Run/Wait)` concurrently. Without serialization a + /// stale `Wait` from session A can clobber session B's freshly acquired + /// grab on any desktop OS. + /// + /// Windows and macOS are less susceptible in practice because the Flutter + /// side triggers `enterView` only after a mouse click inside the window, + /// but we cannot rely on that. On Linux/X11, `XGrabKeyboard` can also + /// cause a focus-change feedback loop (~10 Hz), so `last_grab` debounces + /// spurious `Wait` events that arrive shortly after a `Run`. + #[derive(Default)] + struct GrabOwnerState { + owner: Option, + last_grab: Option, + /// True while a deferred-release thread is in flight. Prevents + /// spawning redundant threads during the X11 feedback loop. + deferred_pending: bool, + } + + /// How long after a grab acquisition we suppress Wait from the same session. + /// Must exceed one full X11 feedback cycle (~100 ms: 50 ms enable + 50 ms disable). + #[cfg(target_os = "linux")] + const GRAB_DEBOUNCE_MS: u128 = 300; + lazy_static::lazy_static! { static ref IS_GRAB_STARTED: Arc> = Arc::new(Mutex::new(false)); + static ref GRAB_STATE: Arc> = Arc::new(Mutex::new(GrabOwnerState::default())); + } + + #[cfg(target_os = "linux")] + lazy_static::lazy_static! { + static ref GRAB_OP_LOCK: Mutex<()> = Mutex::new(()); + } + + #[cfg(target_os = "linux")] + fn apply_run_grab_if_owner(session_id: u128, disable_first: bool) { + let _lock = GRAB_OP_LOCK.lock().unwrap(); + let gs = GRAB_STATE.lock().unwrap(); + if gs.owner != Some(session_id) { + return; + } + drop(gs); + if disable_first { + log::debug!("[grab] handoff: disable_grab before re-grab"); + rdev::disable_grab(); + } + rdev::enable_grab(); + } + + #[cfg(target_os = "linux")] + fn disable_grab_if_released() { + let _lock = GRAB_OP_LOCK.lock().unwrap(); + let should_disable = { + let gs = GRAB_STATE.lock().unwrap(); + gs.owner.is_none() && gs.last_grab.is_none() + }; + if should_disable { + rdev::disable_grab(); + } } pub fn start_grab_loop() { @@ -96,36 +155,167 @@ pub mod client { } #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub fn change_grab_status(state: GrabState, keyboard_mode: &str) { + pub fn change_grab_status(state: GrabState, keyboard_mode: &str, session_id: u128) { #[cfg(feature = "flutter")] if !IS_RDEV_ENABLED.load(Ordering::SeqCst) { return; } + // Serialize transitions so a stale `Wait` from a previous owner cannot + // clobber a fresh `Run` from a different session window. + let mut release_after_unlock = None; + #[cfg(target_os = "linux")] + let mut run_grab_after_unlock = None; + #[cfg(target_os = "linux")] + let mut disable_after_unlock = false; + let mut gs = GRAB_STATE.lock().unwrap(); match state { GrabState::Ready => {} GrabState::Run => { #[cfg(windows)] update_grab_get_key_name(keyboard_mode); + + // Idempotent: if this session already owns the grab, just + // refresh the debounce timer (proves the session is still + // actively focused) and skip the actual grab call. + if gs.owner == Some(session_id) { + gs.last_grab = Some(std::time::Instant::now()); + // Reset so the next Wait can spawn a fresh deferred-release + // timer with an up-to-date snapshot of last_grab. + gs.deferred_pending = false; + log::debug!( + "[grab] Run(0x{:x}): already owner, refresh debounce", + session_id + ); + return; + } + + log::debug!( + "[grab] Run(0x{:x}): prev_owner={}, mode={}", + session_id, + gs.owner + .map_or("none".to_string(), |id| format!("0x{:x}", id)), + keyboard_mode, + ); + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] - KEYBOARD_HOOKED.swap(true, Ordering::SeqCst); + KEYBOARD_HOOKED.store(true, Ordering::SeqCst); #[cfg(target_os = "linux")] - rdev::enable_grab(); + let had_owner = gs.owner.is_some(); + gs.owner = Some(session_id); + gs.last_grab = Some(std::time::Instant::now()); + // Invalidate any in-flight deferred release from the previous + // owner so it cannot suppress a fresh timer for the new owner. + gs.deferred_pending = false; + #[cfg(target_os = "linux")] + { + run_grab_after_unlock = Some(had_owner); + } } GrabState::Wait => { + // Drop stale `Wait` events that do not correspond to the + // current grab owner. This prevents a late PointerExit from + // session A from releasing session B's freshly acquired grab. + if gs.owner != Some(session_id) { + log::debug!( + "[grab] Wait(0x{:x}): ignored, owner={}", + session_id, + gs.owner + .map_or("none".to_string(), |id| format!("0x{:x}", id)), + ); + return; + } + + // Debounce: on Linux/X11, XGrabKeyboard causes a focus-change + // feedback loop (grab -> PointerExit -> ungrab -> PointerEnter -> + // grab -> ...). Suppress Wait if the grab was acquired recently + // by this same session -- it is X11 feedback, not a real leave. + // A deferred release is scheduled so that a genuine leave within + // the debounce window is not permanently lost. + #[cfg(target_os = "linux")] + if let Some(t) = gs.last_grab { + let elapsed = t.elapsed().as_millis(); + if elapsed < GRAB_DEBOUNCE_MS { + if !gs.deferred_pending { + log::debug!( + "[grab] Wait(0x{:x}): debounced ({}ms < {}ms), scheduling deferred release", + session_id, elapsed, GRAB_DEBOUNCE_MS, + ); + gs.deferred_pending = true; + let remaining = (GRAB_DEBOUNCE_MS - elapsed) as u64 + 50; + let snapshot = gs.last_grab; + let mode = keyboard_mode.to_string(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(remaining)); + let release_keys = { + let mut gs = GRAB_STATE.lock().unwrap(); + // Release only if no new Run has refreshed the grab since. + if gs.owner == Some(session_id) && gs.last_grab == snapshot { + let to_release = take_remote_keys(); + gs.deferred_pending = false; + log::debug!( + "[grab] Wait(0x{:x}): deferred release", + session_id + ); + KEYBOARD_HOOKED.store(false, Ordering::SeqCst); + gs.owner = None; + gs.last_grab = None; + Some(to_release) + } else { + log::debug!( + "[grab] Wait(0x{:x}): deferred release cancelled (grab refreshed)", + session_id, + ); + None + } + }; + if let Some(to_release) = release_keys { + disable_grab_if_released(); + release_remote_keys_for_events(&mode, to_release); + } + }); + } else { + log::debug!( + "[grab] Wait(0x{:x}): debounced, deferred release already pending", + session_id, + ); + } + return; + } + } + + log::debug!("[grab] Wait(0x{:x}): releasing grab", session_id); + #[cfg(windows)] rdev::set_get_key_unicode(false); - release_remote_keys(keyboard_mode); - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] - KEYBOARD_HOOKED.swap(false, Ordering::SeqCst); + KEYBOARD_HOOKED.store(false, Ordering::SeqCst); + gs.owner = None; + gs.last_grab = None; + gs.deferred_pending = false; + release_after_unlock = Some(take_remote_keys()); #[cfg(target_os = "linux")] - rdev::disable_grab(); + { + disable_after_unlock = true; + } } GrabState::Exit => {} } + drop(gs); + #[cfg(target_os = "linux")] + { + if disable_after_unlock { + disable_grab_if_released(); + } + if let Some(disable_first) = run_grab_after_unlock { + apply_run_grab_if_owner(session_id, disable_first); + } + } + if let Some(to_release) = release_after_unlock { + release_remote_keys_for_events(keyboard_mode, to_release); + } } pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option) { @@ -341,7 +531,6 @@ fn notify_exit_relative_mouse_mode() { flutter::push_session_event(&session_id, "exit_relative_mouse_mode", vec![]); } - /// Handle relative mouse mode shortcuts in the rdev grab loop. /// Returns true if the event should be blocked from being sent to the peer. #[cfg(feature = "flutter")] @@ -540,10 +729,12 @@ pub fn is_long_press(event: &Event) -> bool { return false; } -pub fn release_remote_keys(keyboard_mode: &str) { - // todo!: client quit suddenly, how to release keys? - let to_release = TO_RELEASE.lock().unwrap().clone(); - TO_RELEASE.lock().unwrap().clear(); +fn take_remote_keys() -> HashMap { + let mut to_release = TO_RELEASE.lock().unwrap(); + std::mem::take(&mut *to_release) +} + +fn release_remote_keys_for_events(keyboard_mode: &str, to_release: HashMap) { for (key, mut event) in to_release.into_iter() { event.event_type = EventType::KeyRelease(key); client::process_event(keyboard_mode, &event, None); @@ -558,6 +749,12 @@ pub fn release_remote_keys(keyboard_mode: &str) { } } +#[allow(dead_code)] +pub fn release_remote_keys(keyboard_mode: &str) { + // todo!: client quit suddenly, how to release keys? + release_remote_keys_for_events(keyboard_mode, take_remote_keys()); +} + pub fn get_keyboard_mode_enum(keyboard_mode: &str) -> KeyboardMode { match keyboard_mode { "map" => KeyboardMode::Map, @@ -748,7 +945,6 @@ pub fn event_to_key_events( ) -> Vec { peer.retain(|c| !c.is_whitespace()); - let mut key_event = KeyEvent::new(); update_modifiers_state(event); match event.event_type { @@ -761,6 +957,7 @@ pub fn event_to_key_events( _ => {} } + let mut key_event = KeyEvent::new(); key_event.mode = keyboard_mode.into(); let mut key_events = match keyboard_mode { diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 6d48e34ee..4113c1391 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "اسم العرض"), ("password-hidden-tip", "كلمة المرور مخفية"), ("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 5ea7c3351..1a3260c5a 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Імя для адлюстравання"), ("password-hidden-tip", "Зададзены пастаянны пароль (скрыты)."), ("preset-password-in-use-tip", "Пададзены пароль цяпер выкарыстоўваецца"), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 218070291..17a89ce07 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 2f1cc8734..799ca951f 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 75d16ff92..1ff10c49d 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "显示名称"), ("password-hidden-tip", "永久密码已设置(已隐藏)"), ("preset-password-in-use-tip", "当前使用预设密码"), + ("Enable privacy mode", "允许隐私模式"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 7b3dc7908..2b9c6219e 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 06ad254c7..7410124df 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 39e077348..030bc626d 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Anzeigename"), ("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."), ("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."), + ("Enable privacy mode", "Datenschutzmodus aktivieren"), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 38e11bfce..0633889a7 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Εμφανιζόμενο όνομα"), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 921f79612..16d43c9b4 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 0f49079a2..2e543c25e 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index d65cd31c5..a00c312b8 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index f12ecf371..aaf8a8be8 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 5f6d5f005..d34e4239e 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 43c033a11..1bddd39d1 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 0dda7817f..6f7bb2880 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -741,7 +741,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-incoming-sessions-label", "Maintenir l’écran allumé lors des sessions entrantes"), ("Continue with {}", "Continuer avec {}"), ("Display Name", "Nom d’affichage"), - ("password-hidden-tip", ""), - ("preset-password-in-use-tip", ""), + ("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."), + ("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."), + ("Enable privacy mode", "Activer le mode de confidentialité"), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index dc78bc0d9..fba2fd83d 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 39c45597c..8b8568c85 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -742,5 +742,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "ડિસ્પ્લે નામ"), ("password-hidden-tip", "સુરક્ષા માટે પાસવર્ડ છુપાવેલ છે."), ("preset-password-in-use-tip", "પ્રીસેટ પાસવર્ડ વપરાશમાં છે."), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 741805e25..682ee0c46 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 2d596bacc..505b01df9 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 2ba49a0cf..7f9b3299e 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Kijelző név"), ("password-hidden-tip", "Állandó jelszó lett beállítva (rejtett)."), ("preset-password-in-use-tip", "Jelenleg az alapértelmezett jelszót használja."), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 356a9ee2d..bbd95e79a 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 1b6e49691..479551fcc 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Visualizza nome"), ("password-hidden-tip", "È impostata una password permanente (nascosta)."), ("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."), + ("Enable privacy mode", "Abilita modalità privacy"), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 56faba383..20caca0a7 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "表示名"), ("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"), ("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 7cc0c9067..7b3ffd98e 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "표시 이름"), ("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."), ("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index e943ff4cd..a2a1624f7 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index a4f39f1e4..82422c30a 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 838984207..906d056bd 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index d9cf6ad38..5795b9eeb 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 6d140daad..833c947cf 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Naam Weergeven"), ("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."), ("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 2000de2c8..972afc170 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Nazwa wyświetlana"), ("password-hidden-tip", "Ustawiono (ukryto) stare hasło."), ("preset-password-in-use-tip", "Obecnie używane jest hasło domyślne."), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 0cdcf93b4..899c8da71 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index f9bae32b1..4eb2c1544 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 7ace3f736..45b22684e 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Nume afișat"), ("password-hidden-tip", "Parola este ascunsă din motive de securitate. Fă clic pe pictograma ochiului pentru a o afișa."), ("preset-password-in-use-tip", "Se folosește o parolă prestabilită. Se recomandă setarea unei parole personalizate pentru securitate sporită."), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 14bc96390..3917c6fa2 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Отображаемое имя"), ("password-hidden-tip", "Установлен постоянный пароль (скрытый)."), ("preset-password-in-use-tip", "Установленный пароль сейчас используется."), + ("Enable privacy mode", "Использовать режим конфиденциальности"), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index f2c4fbfa2..68ce541f2 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index d0e99b2a4..6b4e16688 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index aef6b7c66..3f35dea88 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 5f9d5505b..f7f6c16d4 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 19ae6896f..bedbe4856 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 7ad257fcb..eda7851c1 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 2cee45268..6e5652560 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index ff755768c..5e25801d2 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 2d3eb1d34..c2d058c98 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 5acb15221..d93ad4f68 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -741,7 +741,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranı açık tutun"), ("Continue with {}", "{} ile devam et"), ("Display Name", "Görünen Ad"), - ("password-hidden-tip", "Şifre gizli"), - ("preset-password-in-use-tip", "Önceden ayarlanmış şifre kullanılıyor"), + ("password-hidden-tip", "Parola gizli"), + ("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"), + ("Enable privacy mode", "Gizlilik modunu etkinleştir"), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 5211cc92b..b23b84949 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "顯示名稱"), ("password-hidden-tip", "固定密碼已設定(已隱藏)"), ("preset-password-in-use-tip", "目前正在使用預設密碼"), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 2594b7cc3..3e1c4f25e 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 6939b2ea1..3fadb0efc 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", ""), ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), ].iter().cloned().collect(); } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9493e1cae..7157da760 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -6,7 +6,7 @@ use hbb_common::{ anyhow::anyhow, bail, config::{keys::OPTION_ALLOW_LINUX_HEADLESS, Config}, - libc::{c_char, c_int, c_long, c_uint, c_void}, + libc::{c_char, c_int, c_long, c_uint, c_ulong, c_void}, log, message_proto::{DisplayInfo, Resolution}, regex::{Captures, Regex}, @@ -97,10 +97,55 @@ thread_local! { static DISPLAY: RefCell<*mut c_void> = RefCell::new(unsafe { XOpenDisplay(std::ptr::null())}); } +// X11 error event structure for the custom error handler. +// See: https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Using-the-Default-Error-Handlers +#[repr(C)] +struct XErrorEvent { + type_: c_int, + display: *mut c_void, // Display* + resourceid: c_ulong, // XID + serial: c_ulong, + error_code: u8, + request_code: u8, + minor_code: u8, +} + +type XErrorHandler = unsafe extern "C" fn(*mut c_void, *mut XErrorEvent) -> c_int; + +const X11_BAD_WINDOW: u8 = 3; +const XDO_SUCCESS: c_int = 0; +const XDO_ERROR: c_int = 1; + +/// Atomic flag set by the custom X error handler when a BadWindow error occurs. +static X_BAD_WINDOW_DETECTED: AtomicBool = AtomicBool::new(false); +static X_UNEXPECTED_ERROR_DETECTED: AtomicBool = AtomicBool::new(false); + +/// Custom X error handler that catches BadWindow errors (error_code == 3) instead of +/// letting the default handler terminate the process. +/// See issue: https://github.com/rustdesk/rustdesk/issues/9003 +unsafe extern "C" fn handle_x_error(_display: *mut c_void, event: *mut XErrorEvent) -> c_int { + if !event.is_null() && (*event).error_code == X11_BAD_WINDOW { + X_BAD_WINDOW_DETECTED.store(true, Ordering::SeqCst); + log::debug!("Caught X11 BadWindow error (suppressed), window was likely destroyed"); + return 0; + } + X_UNEXPECTED_ERROR_DETECTED.store(true, Ordering::SeqCst); + if !event.is_null() { + log::warn!( + "X11 error: error_code={}, request_code={}, minor_code={}", + (*event).error_code, + (*event).request_code, + (*event).minor_code, + ); + } + 0 +} + #[link(name = "X11")] extern "C" { fn XOpenDisplay(display_name: *const c_char) -> *mut c_void; // fn XCloseDisplay(d: *mut c_void) -> c_int; + fn XSetErrorHandler(handler: Option) -> Option; } #[link(name = "Xfixes")] @@ -231,25 +276,47 @@ pub fn get_focused_display(displays: Vec) -> Option { if libxdo_sys::xdo_get_active_window(*xdo as *const _, &mut window) != 0 { return; } - if libxdo_sys::xdo_get_window_location( + + // XSetErrorHandler is process-global, not scoped to this Display/thread. + // This path is currently called by the single window_focus service thread. + // While installed, this handler can still observe unrelated X11 errors from + // other threads; unexpected errors make this geometry query fail. + X_BAD_WINDOW_DETECTED.store(false, Ordering::SeqCst); + X_UNEXPECTED_ERROR_DETECTED.store(false, Ordering::SeqCst); + let prev_handler = XSetErrorHandler(Some(handle_x_error)); + + let loc_ret = libxdo_sys::xdo_get_window_location( *xdo as *const _, window, &mut x as _, &mut y as _, std::ptr::null_mut(), - ) != 0 - { - return; - } - if libxdo_sys::xdo_get_window_size( - *xdo as *const _, - window, - &mut width, - &mut height, - ) != 0 + ); + let size_ret = if loc_ret == XDO_SUCCESS { + libxdo_sys::xdo_get_window_size( + *xdo as *const _, + window, + &mut width, + &mut height, + ) + } else { + XDO_ERROR + }; + + // Do not call XSync(DISPLAY) here: DISPLAY is a separate + // XOpenDisplay() connection, while libxdo owns the Display* + // used by these geometry queries. These libxdo calls are + // synchronous XGetWindowAttributes-based queries, so the target + // BadWindow is expected to be delivered before the calls return. + XSetErrorHandler(prev_handler); + if X_BAD_WINDOW_DETECTED.load(Ordering::SeqCst) + || X_UNEXPECTED_ERROR_DETECTED.load(Ordering::SeqCst) + || loc_ret != XDO_SUCCESS + || size_ret != XDO_SUCCESS { return; } + let center_x = x + (width / 2) as c_int; let center_y = y + (height / 2) as c_int; res = displays.iter().position(|d| { @@ -2150,7 +2217,10 @@ pub fn clear_gnome_shortcuts_inhibitor_permission() -> ResultType<()> { || err_name == "org.freedesktop.DBus.Error.UnknownObject" || err_name == "org.freedesktop.DBus.Error.ServiceUnknown" { - log::info!("GNOME shortcuts inhibitor permission was not set ({})", err_name); + log::info!( + "GNOME shortcuts inhibitor permission was not set ({})", + err_name + ); Ok(()) } else { bail!("Failed to clear permission: {}", e) diff --git a/src/server/connection.rs b/src/server/connection.rs index 8b4eb0c48..a960daac1 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -73,11 +73,17 @@ lazy_static::lazy_static! { static ref ALIVE_CONNS: Arc::>> = Default::default(); pub static ref AUTHED_CONNS: Arc::>> = Default::default(); pub static ref CONTROL_PERMISSIONS_ARRAY: Arc::>> = Default::default(); - static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); static ref WAKELOCK_SENDER: Arc::>> = Arc::new(Mutex::new(start_wakelock_thread())); static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::>> = Default::default(); } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +lazy_static::lazy_static! { + static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); + static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); +} + fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; @@ -241,6 +247,7 @@ pub struct Connection { restart: bool, recording: bool, block_input: bool, + privacy_mode: bool, control_permissions: Option, last_test_delay: Option, network_delay: u32, @@ -431,6 +438,7 @@ impl Connection { restart: Self::permission(keys::OPTION_ENABLE_REMOTE_RESTART, &control_permissions), recording: Self::permission(keys::OPTION_ENABLE_RECORD_SESSION, &control_permissions), block_input: Self::permission(keys::OPTION_ENABLE_BLOCK_INPUT, &control_permissions), + privacy_mode: Self::permission(keys::OPTION_ENABLE_PRIVACY_MODE, &control_permissions), control_permissions, last_test_delay: None, network_delay: 0, @@ -527,6 +535,9 @@ impl Connection { if !conn.block_input { conn.send_permission(Permission::BlockInput, false).await; } + if !conn.privacy_mode { + conn.send_permission(Permission::PrivacyMode, false).await; + } let mut test_delay_timer = crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT)); let mut last_recv_time = Instant::now(); @@ -674,6 +685,46 @@ impl Connection { } else if &name == "block_input" { conn.block_input = enabled; conn.send_permission(Permission::BlockInput, enabled).await; + } else if &name == "privacy_mode" { + // Keep permission state and runtime state consistent: + // when revoking the permission, try to leave privacy mode first. + // Otherwise we could end up in an inconsistent state where + // permission looks disabled while privacy mode is still active. + if !enabled && privacy_mode::is_in_privacy_mode() { + if let Some(conn_id) = privacy_mode::get_privacy_mode_conn_id() { + if conn_id == conn.inner.id() { + let impl_key = + privacy_mode::get_cur_impl_key().unwrap_or_default(); + let turn_off_res = + privacy_mode::turn_off_privacy(conn_id, None); + match turn_off_res { + Some(Ok(_)) => { + let msg_out = crate::common::make_privacy_mode_msg( + back_notification::PrivacyModeState::PrvOffByPeer, + impl_key.clone(), + ); + conn.send(msg_out).await; + } + _ => { + let msg_out = Self::turn_off_privacy_result_to_msg( + turn_off_res, + impl_key, + ); + conn.send(msg_out).await; + // Turn-off failed, so revert CM's optimistic toggle + // and keep the previous permission value. + conn.send_to_cm(ipc::Data::SwitchPermission { + name: "privacy_mode".to_owned(), + enabled: conn.privacy_mode, + }); + continue; + } + } + } + } + } + conn.privacy_mode = enabled; + conn.send_permission(Permission::PrivacyMode, enabled).await; } } ipc::Data::RawMessage(bytes) => { @@ -730,6 +781,8 @@ impl Connection { log::error!("Failed to start portable service from cm: {:?}", e); } } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] ipc::Data::SwitchSidesBack => { let mut misc = Misc::new(); misc.set_switch_back(SwitchBack::default()); @@ -978,7 +1031,7 @@ impl Connection { if let Some(video_privacy_conn_id) = privacy_mode::get_privacy_mode_conn_id() { if video_privacy_conn_id == id { - let _ = Self::turn_off_privacy_to_msg(id); + let _ = Self::turn_off_privacy_to_msg(id, String::new()); } } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] @@ -1900,6 +1953,7 @@ impl Connection { restart: self.restart, recording: self.recording, block_input: self.block_input, + privacy_mode: self.privacy_mode, from_switch: self.from_switch, }); } @@ -2175,6 +2229,7 @@ impl Connection { keys::OPTION_ENABLE_REMOTE_RESTART => Some(Permission::restart), keys::OPTION_ENABLE_RECORD_SESSION => Some(Permission::recording), keys::OPTION_ENABLE_BLOCK_INPUT => Some(Permission::block_input), + keys::OPTION_ENABLE_PRIVACY_MODE => Some(Permission::privacy_mode), _ => None, }; if let Some(permission) = permission { @@ -2532,6 +2587,7 @@ impl Connection { } } else if let Some(message::Union::SwitchSidesResponse(_s)) = msg.union { #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(lr) = _s.lr.clone().take() { self.handle_login_request_without_validation(&lr).await; SWITCH_SIDES_UUID @@ -3247,8 +3303,13 @@ impl Connection { } } #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchSidesRequest(s)) => { if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) { + crate::server::insert_pending_switch_sides_uuid( + self.lr.my_id.clone(), + uuid.clone(), + ); crate::run_me(vec![ "--connect", &self.lr.my_id, @@ -4145,6 +4206,15 @@ impl Connection { } async fn turn_on_privacy(&mut self, impl_key: String) { + if !self.is_authed_remote_conn() || !self.privacy_mode { + let msg_out = crate::common::make_privacy_mode_msg( + back_notification::PrivacyModeState::PrvOnFailedDenied, + impl_key, + ); + self.send(msg_out).await; + return; + } + let msg_out = if !privacy_mode::is_privacy_mode_supported() { crate::common::make_privacy_mode_msg_with_details( back_notification::PrivacyModeState::PrvNotSupported, @@ -4186,7 +4256,7 @@ impl Connection { "Check privacy mode failed: {}, turn off privacy mode.", &err_msg ); - let _ = Self::turn_off_privacy_to_msg(self.inner.id); + let _ = Self::turn_off_privacy_to_msg(self.inner.id, String::new()); crate::common::make_privacy_mode_msg_with_details( back_notification::PrivacyModeState::PrvOnFailed, err_msg, @@ -4205,6 +4275,7 @@ impl Connection { if privacy_mode::is_in_privacy_mode() { let _ = Self::turn_off_privacy_to_msg( privacy_mode::INVALID_PRIVACY_MODE_CONN_ID, + String::new(), ); } crate::common::make_privacy_mode_msg_with_details( @@ -4232,14 +4303,23 @@ impl Connection { impl_key, ) } else { - Self::turn_off_privacy_to_msg(self.inner.id) + Self::turn_off_privacy_to_msg(self.inner.id, impl_key) }; self.send(msg_out).await; } - pub fn turn_off_privacy_to_msg(_conn_id: i32) -> Message { - let impl_key = "".to_owned(); - match privacy_mode::turn_off_privacy(_conn_id, None) { + pub fn turn_off_privacy_to_msg(_conn_id: i32, impl_key: String) -> Message { + Self::turn_off_privacy_result_to_msg( + privacy_mode::turn_off_privacy(_conn_id, None), + impl_key, + ) + } + + fn turn_off_privacy_result_to_msg( + turn_off_res: Option>, + impl_key: String, + ) -> Message { + match turn_off_res { Some(Ok(_)) => crate::common::make_privacy_mode_msg( back_notification::PrivacyModeState::PrvOffSucceeded, impl_key, @@ -4872,6 +4952,8 @@ impl Connection { } } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { SWITCH_SIDES_UUID .lock() @@ -4879,6 +4961,27 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { .insert(id, (tokio::time::Instant::now(), uuid)); } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) { + let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); + uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); + uuids.insert(id, (tokio::time::Instant::now(), uuid)); +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { + let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); + uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); + if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) { + uuids.remove(id); + true + } else { + false + } +} + #[cfg(not(any(target_os = "android", target_os = "ios")))] async fn start_ipc( mut rx_to_cm: mpsc::UnboundedReceiver, diff --git a/src/ui.rs b/src/ui.rs index 154319ce4..6d0d0927a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -372,6 +372,11 @@ impl UI { is_installed() } + fn get_supported_privacy_mode_impls(&self) -> String { + serde_json::to_string(&crate::privacy_mode::get_supported_privacy_mode_impl()) + .unwrap_or_default() + } + fn is_root(&self) -> bool { is_root() } @@ -752,6 +757,7 @@ impl sciter::EventHandler for UI { fn get_icon(); fn install_me(String, String); fn is_installed(); + fn get_supported_privacy_mode_impls(); fn is_root(); fn is_release(); fn set_socks(String, String, String); diff --git a/src/ui/cm.css b/src/ui/cm.css index ba6de887b..3ac6c7be3 100644 --- a/src/ui/cm.css +++ b/src/ui/cm.css @@ -93,6 +93,13 @@ div.permissions > div:active { opacity: 0.5; } +div.permissions.locked, +div.permissions.locked *, +div.permissions.locked > div:active { + cursor: default !important; + opacity: 1; +} + icon.keyboard { background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAgVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9d3yJTAAAAKnRSTlMA0Gd/0y8ILZgbJffDPUwV2nvzt+TMqZxyU7CMb1pYQyzsvKunkXE4AwJnNC24AAAA+0lEQVQ4y83O2U7DMBCF4ZMxk9rZk26kpQs7nPd/QJy4EiLbLf01N5Y/2YP/qxDFQvGB5NPC/ZpVnfJx4b5xyGfF95rkHvNCWH1u+N6J6T0sC7gqRy8uGPfBLEbozPXUjlkQKwGaFPNizwQbwkx0TDvhCii34ExZCSQVBdzIOEOyeclSHgBGXkpeygXSQgStACtWx4Z8rr8COHOvfEP/IbbsQAToFUAAV1M408IIjIGYAPoCSNRP7DQutfQTqxuAiH7UUg1FaJR2AGrrx52sK2ye28LZ0wBAEyR6y8X+NADhm1B4fgiiHXbRrTrxpwEY9RdM9wsepnvFHfUDwYEeiwAJr/gAAAAASUVORK5CYII='); } @@ -121,6 +128,10 @@ icon.block_input { background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAjdJREFUWEe1V8tNAzEQfXOHAx2QG0UgQSqBFIIgHdABoQqOhBq4cCMlcMh90FvZq/HEXtvJxlKUZNceP783no+gY6jqNYBHAHcA+JufXTDBb37eRWTbalZqE82mz7W55v0ABMBGRCLA7PJJAKr6AiC3sT11NHyf2SEyQjvtAMKp3wBYo9VTGbYegjxxU65d5tg4YEBVbwF8ALgw2lLX4in80QqyZUEkAMLCb7P5n4hcdWifTA32Pg0bByA8AE4+oL3n9A1s7ERkEeeNAJzD/QC4OVaCAgjrU7wdK86zAHREJSKqyvvORRxVb67JFOT4NfYGpxwAqCo34oYcKxHZhOdzg7D2BhYigHj6RJ+5QbjrPezlqR61sZTOKYfztSUBWPoXpdA5FwjnC2sCGK+eiNRC8yw+oap0RiayLQHEPwf65zx7DibMoXcEEB0wq/85QJQAbEVkWbvP8f0pTFi/65ZgjtuRyJ7QYWL0OZnwTmiLDobH5nLqGDlUlcmON49jQwnsg/Wxma/VJ1zcGQIR7+OYJGyqbJWhhwlDPxh3JpNRL4Ba7nAsJckoYaFUv7UCyslBvQ3TNDWEfVsPJGH2FCkKTPAxD8ox+poFwJfZqqX15H6eYyK+TgJeriidLCJ7wAQHZ4Udy7u9iFxaG7mynEx4EF1leZDANzV7AE8i8joJICz2cvBxbExIYTZYTTQmxTxTzP+VnvC8rZlLOLEj7m5OW6JqtTs2US6247Hvy7XnX0OV05FP/gHde5fLZaGS8AAAAABJRU5ErkJggg=='); } +icon.privacy_mode { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAB7UlEQVR4AdyTrVYDMRCFuyjqiiuuOJA46sCVR6jDgQTXN+CgQIJCgkOCA0cduOLAgaOOuuW7czYhyWY5FcXQc28n85O5m9nsUuuPf/9IoCzLLnxd9MTCET3SvNckQnwL7lfcpnYueIGiKNbY8QYjERo+wZK4HuAcK94rVvGSWCO8gCqKjAixTXLPsAl7ldBxriASqAo6lfUnqUTaWAP5FajTYjxGCNXeYSRAwSflToBlKxSZKSCiMoUa6Uh+QNW/B37LC9D8lkTYHNegTf7JqNP8b5RB5AT7AkPoNqqXxUyATT28AUzhRuFFaLpDUYc9V1ihr7+EA/JdxUyAxQTWQDM3CuVSEWugGiUztJ5OIJPPhlKRbFEVXJZ1Anph8iNyTCsieA0dvIgCQY3ckBtyTIBjfuDcwRR2TPJDElkRcrpd6XcyJm7X2ATY3CKwi1UxxkNPeyiP/BAa8LVZObtdBMOPcYbvX7wXYJNE2lidBuNxyhgm0I1LCdcgFXmguXqoxhgJKELBKvYMhljH+ULEwDr8mEIRXWHSP6gJKIXIESxYh3PHzWJK1IuwjpAVcBWIhHPX0x2QE/vkHGofIzUevwr4KhZ003wvsOKYkAcxXfPoxbvk3AJuQ5MNRNwFsNKFCaibRGB0CxcqIJGU3wAAAP//8GtoDAAAAAZJREFUAwCJJuAxFVNbWwAAAABJRU5ErkJggg=='); +} + div.outer_buttons { flow:vertical; border-spacing:8; diff --git a/src/ui/cm.rs b/src/ui/cm.rs index 8eb8f494e..4a68a571d 100644 --- a/src/ui/cm.rs +++ b/src/ui/cm.rs @@ -36,7 +36,8 @@ impl InvokeUiCM for SciterHandler { client.file, client.restart, client.recording, - client.block_input + client.block_input, + client.privacy_mode ), ); } @@ -157,9 +158,18 @@ impl SciterConnectionManager { crate::ui_interface::get_option(key) } + fn get_builtin_option(&self, key: String) -> String { + crate::ui_interface::get_builtin_option(&key) + } + fn hide_cm(&self) -> bool { *crate::ui::cm::HIDE_CM.lock().unwrap() } + + fn get_supported_privacy_mode_impls(&self) -> String { + serde_json::to_string(&crate::privacy_mode::get_supported_privacy_mode_impl()) + .unwrap_or_default() + } } impl sciter::EventHandler for SciterConnectionManager { @@ -181,6 +191,8 @@ impl sciter::EventHandler for SciterConnectionManager { fn can_elevate(); fn elevate_portable(i32); fn get_option(String); + fn get_builtin_option(String); fn hide_cm(); + fn get_supported_privacy_mode_impls(); } } diff --git a/src/ui/cm.tis b/src/ui/cm.tis index a06fb9ff8..f306e9032 100644 --- a/src/ui/cm.tis +++ b/src/ui/cm.tis @@ -4,6 +4,9 @@ var body; var connections = []; var show_chat = false; var show_elevation = true; +var is_privacy_mode_supported = handler.get_supported_privacy_mode_impls() != '[]'; +var allow_perm_change_in_accept_window = + handler.get_builtin_option('enable-perm-change-in-accept-window') != 'N'; var svg_elevate = ; var hide_cm = undefined; @@ -35,6 +38,7 @@ class Body: Reactor.Component me.sendMsg(msg); }; var right_style = show_chat ? "" : "display: none"; + var permissions_locked = !allow_perm_change_in_accept_window; var disconnected = c.disconnected; var show_elevation_btn = handler.can_elevate() && show_elevation && !c.is_file_transfer && !c.is_view_camera && !c.is_terminal && c.port_forward.length == 0; var show_accept_btn = handler.get_option('approve-mode') != 'password'; @@ -58,15 +62,16 @@ class Body: Reactor.Component
{c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" :
{translate('Permissions')}
} - {c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" :
+ {c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" :
-
+
+
} {c.is_file_transfer ?
{translate('Transfer file')}
: ""} @@ -103,6 +108,7 @@ class Body: Reactor.Component } event click $(icon.keyboard) (e) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.keyboard = !connection.keyboard; @@ -112,6 +118,7 @@ class Body: Reactor.Component } event click $(icon.clipboard) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.clipboard = !connection.clipboard; @@ -121,6 +128,7 @@ class Body: Reactor.Component } event click $(icon.audio) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.audio = !connection.audio; @@ -130,6 +138,7 @@ class Body: Reactor.Component } event click $(icon.file) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.file = !connection.file; @@ -139,6 +148,7 @@ class Body: Reactor.Component } event click $(icon.restart) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.restart = !connection.restart; @@ -148,6 +158,7 @@ class Body: Reactor.Component } event click $(icon.recording) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.recording = !connection.recording; @@ -157,6 +168,7 @@ class Body: Reactor.Component } event click $(icon.block_input) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.block_input = !connection.block_input; @@ -165,6 +177,16 @@ class Body: Reactor.Component }); } + event click $(icon.privacy_mode) { + if (!allow_perm_change_in_accept_window) return; + var { cid, connection } = this; + checkClickTime(function() { + connection.privacy_mode = !connection.privacy_mode; + body.update(); + handler.switch_permission(cid, "privacy_mode", connection.privacy_mode); + }); + } + event click $(button#accept) { var { cid, connection } = this; checkClickTime(function() { @@ -368,7 +390,7 @@ function bring_to_top(idx=-1) { } } -handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) { +handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, privacy_mode) { stdout.println("new connection #" + id + ": " + peer_id); var conn; connections.map(function(c) { @@ -376,6 +398,7 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin }); if (conn) { conn.authorized = authorized; + conn.privacy_mode = privacy_mode; update(); return; } @@ -391,7 +414,7 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin name: name, authorized: authorized, time: new Date(), now: new Date(), keyboard: keyboard, clipboard: clipboard, msgs: [], unreaded: 0, audio: audio, file: file, restart: restart, recording: recording, - block_input:block_input, + block_input:block_input, privacy_mode:privacy_mode, disconnected: false }; if (idx < 0) { @@ -480,15 +503,21 @@ function getElapsed(time, now) { return out; } -var ui_status_cache = [""]; +var ui_status_cache = ["", ""]; function check_update_ui() { self.timer(1s, function() { var approve_mode = handler.get_option('approve-mode'); + var allow_perm_change = handler.get_builtin_option('enable-perm-change-in-accept-window'); var changed = false; if (ui_status_cache[0] != approve_mode) { ui_status_cache[0] = approve_mode; changed = true; } + if (ui_status_cache[1] != allow_perm_change) { + ui_status_cache[1] = allow_perm_change; + allow_perm_change_in_accept_window = allow_perm_change != 'N'; + changed = true; + } if (changed) update(); check_update_ui(); }); diff --git a/src/ui/header.tis b/src/ui/header.tis index 2698ce4d0..40ccbcbf2 100644 --- a/src/ui/header.tis +++ b/src/ui/header.tis @@ -218,7 +218,7 @@ class Header: Reactor.Component { {is_file_copy_paste_supported && file_enabled ?
  • {svg_checkmark}{translate('Enable file copy and paste')}
  • : ""} {keyboard_enabled && clipboard_enabled ?
  • {svg_checkmark}{translate('Disable clipboard')}
  • : ""} {keyboard_enabled ?
  • {svg_checkmark}{translate('Lock after session end')}
  • : ""} - {keyboard_enabled && pi.platform == "Windows" ?
  • {svg_checkmark}{translate('Privacy mode')}
  • : ""} + {(pi.platform == "Windows" || pi.platform == "Mac OS") && (handler.get_toggle_option("privacy-mode") || (keyboard_enabled && privacy_mode_enabled)) ?
  • {svg_checkmark}{translate('Privacy mode')}
  • : ""} {keyboard_enabled && ((is_osx && pi.platform != "Mac OS") || (!is_osx && pi.platform == "Mac OS")) ?
  • {svg_checkmark}{translate('Swap control-command key')}
  • : ""} {handler.version_cmp(pi.version, '1.2.4') >= 0 ?
  • {svg_checkmark}{translate('True color (4:4:4)')}
  • : ""} diff --git a/src/ui/index.tis b/src/ui/index.tis index be826529d..a099b95f9 100644 --- a/src/ui/index.tis +++ b/src/ui/index.tis @@ -521,6 +521,7 @@ class MyIdMenu: Reactor.Component { {!disable_settings &&
  • {svg_checkmark}{translate('Enable remote restart')}
  • } {!disable_settings &&
  • {svg_checkmark}{translate('Enable TCP tunneling')}
  • } {!disable_settings && is_win ?
  • {svg_checkmark}{translate('Enable blocking user input')}
  • : ""} + {!disable_settings && (handler.get_supported_privacy_mode_impls() != '[]') &&
  • {svg_checkmark}{translate('Enable privacy mode')}
  • } {!disable_settings &&
  • {svg_checkmark}{translate('Enable LAN discovery')}
  • } diff --git a/src/ui/remote.tis b/src/ui/remote.tis index 7602432fe..28fbc3763 100644 --- a/src/ui/remote.tis +++ b/src/ui/remote.tis @@ -17,6 +17,7 @@ var audio_enabled = true; // server side var file_enabled = true; // server side var restart_enabled = true; // server side var recording_enabled = true; // server side +var privacy_mode_enabled = true; // server side var scroll_body = $(body); var peer_platform = ""; @@ -588,6 +589,7 @@ handler.setPermission = function(name, enabled) { if (name == "clipboard") clipboard_enabled = enabled; if (name == "restart") restart_enabled = enabled; if (name == "recording") recording_enabled = enabled; + if (name == "privacy_mode") privacy_mode_enabled = enabled; input_blocked = false; header.update(); }); diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 19a9e74e7..cab0d7f1c 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -12,7 +12,10 @@ use hbb_common::fs::serialize_transfer_job; use hbb_common::tokio::sync::mpsc::unbounded_channel; use hbb_common::{ allow_err, bail, - config::{keys::OPTION_FILE_TRANSFER_MAX_FILES, Config}, + config::{ + keys::{OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, OPTION_FILE_TRANSFER_MAX_FILES}, + option2bool, Config, + }, fs::{self, get_string, is_write_need_confirmation, new_send_confirm, DigestCheckResult}, log, message_proto::*, @@ -25,10 +28,7 @@ use hbb_common::{ ResultType, }; #[cfg(target_os = "windows")] -use hbb_common::{ - config::{keys::*, option2bool}, - tokio::sync::Mutex as TokioMutex, -}; +use hbb_common::{config::keys::*, tokio::sync::Mutex as TokioMutex}; use serde_derive::Serialize; #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] use std::iter::FromIterator; @@ -143,6 +143,7 @@ pub struct Client { pub restart: bool, pub recording: bool, pub block_input: bool, + pub privacy_mode: bool, pub from_switch: bool, pub in_voice_call: bool, pub incoming_voice_call: bool, @@ -230,6 +231,7 @@ impl ConnectionManager { restart: bool, recording: bool, block_input: bool, + privacy_mode: bool, from_switch: bool, #[cfg(not(any(target_os = "ios")))] tx: mpsc::UnboundedSender, ) { @@ -251,6 +253,7 @@ impl ConnectionManager { restart, recording, block_input, + privacy_mode, from_switch, #[cfg(not(any(target_os = "ios")))] tx, @@ -392,6 +395,23 @@ pub fn send_chat(id: i32, text: String) { #[inline] #[cfg(not(any(target_os = "ios")))] pub fn switch_permission(id: i32, name: String, enabled: bool) { + #[cfg(target_os = "android")] + let is_keyboard_permission = name == "keyboard"; + #[cfg(not(target_os = "android"))] + let is_keyboard_permission = false; + if !option2bool( + OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ) && !is_keyboard_permission + { + log::info!( + "blocked cm switch_permission by policy, conn_id={}, permission={}, enabled={}", + id, + name, + enabled + ); + return; + } if let Some(client) = CLIENTS.read().unwrap().get(&id) { allow_err!(client.tx.send(Data::SwitchPermission { name, enabled })); }; @@ -400,6 +420,19 @@ pub fn switch_permission(id: i32, name: String, enabled: bool) { #[inline] #[cfg(target_os = "android")] pub fn switch_permission_all(name: String, enabled: bool) { + if name != "keyboard" + && !option2bool( + OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ) + { + log::info!( + "blocked cm switch_permission_all by policy, permission={}, enabled={}", + name, + enabled + ); + return; + } for (_, client) in CLIENTS.read().unwrap().iter() { allow_err!(client.tx.send(Data::SwitchPermission { name: name.clone(), @@ -422,9 +455,16 @@ pub fn get_clients_length() -> usize { clients.len() } +#[inline] +#[cfg(target_os = "android")] +pub fn has_active_clients() -> bool { + let clients = CLIENTS.read().unwrap(); + clients.values().any(|c| !c.disconnected) +} + #[inline] #[cfg(feature = "flutter")] -#[cfg(not(any(target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn switch_back(id: i32) { if let Some(client) = CLIENTS.read().unwrap().get(&id) { allow_err!(client.tx.send(Data::SwitchSidesBack)); @@ -503,9 +543,9 @@ impl IpcTaskRunner { } Ok(Some(data)) => { match data { - Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => { + Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, privacy_mode, from_switch} => { log::debug!("conn_id: {}", id); - self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone()); + self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, privacy_mode, from_switch, self.tx.clone()); self.conn_id = id; #[cfg(target_os = "windows")] { @@ -533,6 +573,26 @@ impl IpcTaskRunner { Data::ChatMessage { text } => { self.cm.new_message(self.conn_id, text); } + Data::SwitchPermission { name, enabled } => { + // Keep this branch scoped to privacy mode rollback. + // Other CM permission toggles are updated optimistically by the UI itself. + // The backend currently sends SwitchPermission back to CM only when + // privacy-mode turn-off fails and the UI state must be restored. + if name == "privacy_mode" { + let client = { + let mut clients = CLIENTS.write().unwrap(); + clients.get_mut(&self.conn_id).map(|c| { + c.privacy_mode = enabled; + c.clone() + }) + }; + if let Some(client) = client { + // This reuses add_connection(), and cm.tis only selectively updates + // existing rows (authorized/privacy_mode) for this fallback path. + self.cm.ui_handler.add_connection(&client); + } + } + } Data::FS(mut fs) => { if let ipc::FS::WriteBlock { id, file_num, data: _, compressed } = fs { if let Ok(bytes) = self.stream.next_raw().await { @@ -835,6 +895,7 @@ pub async fn start_listen( restart, recording, block_input, + privacy_mode, from_switch, .. }) => { @@ -856,6 +917,7 @@ pub async fn start_listen( restart, recording, block_input, + privacy_mode, from_switch, tx.clone(), ); diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index be1895e64..e6c8ac6a2 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -870,12 +870,14 @@ impl Session { #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn enter(&self, keyboard_mode: String) { - keyboard::client::change_grab_status(GrabState::Run, &keyboard_mode); + let session_id = self.lc.read().unwrap().session_id as u128; + keyboard::client::change_grab_status(GrabState::Run, &keyboard_mode, session_id); } #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn leave(&self, keyboard_mode: String) { - keyboard::client::change_grab_status(GrabState::Wait, &keyboard_mode); + let session_id = self.lc.read().unwrap().session_id as u128; + keyboard::client::change_grab_status(GrabState::Wait, &keyboard_mode, session_id); } // flutter only TODO new input @@ -1462,10 +1464,11 @@ impl Session { self.send(Data::ElevateWithLogon(username, password)); } - #[cfg(any(target_os = "ios"))] + #[cfg(any(target_os = "android", target_os = "ios", not(feature = "flutter")))] pub fn switch_sides(&self) {} - #[cfg(not(any(target_os = "ios")))] + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] #[tokio::main(flavor = "current_thread")] pub async fn switch_sides(&self) { match crate::ipc::connect(1000, "").await {