From 03e351ac61255eba956155ff84c7a6d238ebea42 Mon Sep 17 00:00:00 2001 From: Nawer Date: Fri, 24 Apr 2026 12:38:34 +0200 Subject: [PATCH 01/24] feat(i18n): Complete and fix french translations (#14890) --- docs/CODE_OF_CONDUCT-FR.md | 143 +++++++++++++++++++++++++++++++++++++ docs/CONTRIBUTING-FR.md | 55 ++++++++++++++ docs/README-FR.md | 6 +- docs/SECURITY-FR.md | 16 +++++ src/lang/fr.rs | 4 +- 5 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 docs/CODE_OF_CONDUCT-FR.md create mode 100644 docs/CONTRIBUTING-FR.md create mode 100644 docs/SECURITY-FR.md 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/src/lang/fr.rs b/src/lang/fr.rs index 0dda7817f..8ad712f1e 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -741,7 +741,7 @@ 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é."), ].iter().cloned().collect(); } From 38f13007171f395e36a5730e70746a68620a97f7 Mon Sep 17 00:00:00 2001 From: Sergiusz Michalik Date: Sat, 25 Apr 2026 06:46:05 +0200 Subject: [PATCH 02/24] fix(linux): enable mouse side buttons in remote sessions (#14848) * fix(linux): enable mouse side buttons in remote sessions Flutter's Linux embedder never delivers X11 button 8/9 (back/forward) events to Dart, so mouse side buttons were silently dropped in remote sessions. Intercept these buttons at the GDK level via button-press/release-event handlers on all windows (main + sub-windows) and forward them through a dedicated platform channel to the active InputModel session. Also add a defensive XSetPointerMapping call during enigo init to extend the X11 core pointer button map to 9 buttons on servers where it is smaller (e.g. minimal X server configurations). * fix: address review feedback for side button support - Use XOpenDisplay/XCloseDisplay instead of reading Display* from xdo_t's private struct layout at offset 0 (fragile ABI assumption) - Track side button down ownership per button via a Map instead of a single slot, preventing cross-button mismatch on overlapping presses * fix: gate side buttons on view-only and fix teardown - Skip side button events in view-only sessions (consistent with other mouse entry points) - Release held side buttons on session close to avoid stuck buttons on the remote - Drop unpaired 'up' events instead of falling back to the active model, which could send to the wrong session * docs: add clarifying comments from review feedback - Note global scope of XSetPointerMapping and that it runs once via lazy_static singleton - Clarify sub-window callback is safe on X11-only builds - Document per-isolate design of initSideButtonChannel * fix: replace broken XSetPointerMapping with diagnostic check XSetPointerMapping requires the length to match XGetPointerMapping's return value - it cannot extend the button count. The previous code would trigger a BadValue X error on servers with fewer than 9 buttons. Replace with a diagnostic-only check that logs whether the core pointer has enough buttons for side button simulation. RustDesk's uinput "Mouse passthrough" device already provides the needed buttons in practice. Also add .catchError to fire-and-forget side button releases during session teardown to prevent unhandled async errors. * fix: ensure side button releases bypass permission checks If permissions change between button down and up (e.g. keyboardPerm revoked, view-only toggled), sendMouse's early return would suppress the release, leaving a stuck button on the remote. Add _sendMouseUnchecked that bypasses permission checks, used for: - Side button 'up' events (matching a recorded 'down') - Forced releases during session teardown Gate all permission checks (isViewOnly, keyboardPerm, isViewCamera) at the 'down' entry point before recording in _sideButtonDownModels. * fix: add NULL guards and avoid blocking platform channel handler - Add NULL checks for FL_VIEW cast and channel creation in on_subwindow_created (review feedback from fufesou) - Use fire-and-forget (unawaited) for _sendMouseUnchecked calls inside the platform channel handler to avoid blocking platform messages when sessionSendMouse is slow (review feedback from Copilot) * fix: remove circular import and skip X11 check on Wayland - Move initSideButtonChannel() call from initEnv() in main.dart to the InputModel constructor, removing the circular import between main.dart and input_model.dart - Skip check_x11_button_map() when DISPLAY is not set to avoid noisy warnings on pure Wayland environments --- flutter/lib/models/input_model.dart | 99 +++++++++++++++++++++++++++-- flutter/lib/models/model.dart | 1 + flutter/linux/my_application.cc | 89 ++++++++++++++++++++++++-- libs/enigo/src/linux/xdo.rs | 47 ++++++++++++++ 4 files changed, 227 insertions(+), 9 deletions(-) diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 675a95e42..ab9278217 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -20,7 +20,7 @@ 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 +157,8 @@ extension ToString on MouseButtons { return 'wheel'; case MouseButtons.back: return 'back'; + case MouseButtons.forward: + return 'forward'; } } } @@ -327,6 +329,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 +488,7 @@ class InputModel { bool get isRelativeMouseModeSupported => _relativeMouse.isSupported; InputModel(this.parent) { + initSideButtonChannel(); sessionId = parent.target!.sessionId; _relativeMouse = RelativeMouseModel( sessionId: sessionId, @@ -966,13 +1043,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 +1066,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(); 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/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/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, From 3a1622e8b5664e5d76d0ca8b74646cce30c15e48 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 26 Apr 2026 21:25:31 +0800 Subject: [PATCH 03/24] refact(AGENTS.md): code rules, tokio (#14911) * refact(AGENTS.md): code rules, tokio Signed-off-by: fufesou * Update AGENTS.md * Update AGENTS.md * Update AGENTS.md * Update AGENTS.md --------- Signed-off-by: fufesou Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> --- AGENTS.md | 122 +++++++++++++++++------------------------------------- 1 file changed, 39 insertions(+), 83 deletions(-) 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. From 5ea6714db8c47e4eb660c22cd55208ce3dce828d Mon Sep 17 00:00:00 2001 From: Azhar Date: Sun, 26 Apr 2026 18:58:05 +0530 Subject: [PATCH 04/24] Fix: replace unwrap() with proper error handling in CLI password prompt (#14910) Signed-off-by: bunnysayzz --- src/cli.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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(), From c8ba99d1a1c5c293e7b5ab9b3abc1bb5f3cc0cb9 Mon Sep 17 00:00:00 2001 From: Amirhosein Akhlaghpoor Date: Sun, 26 Apr 2026 14:44:26 +0000 Subject: [PATCH 05/24] flutter: shift after one shot IME capitalization (#14695) * flutter: shift after one shot IME capitalization Signed-off-by: Amirhossein Akhlaghpour * flutter: clarify stale mobile shift handling Signed-off-by: Amirhossein Akhlaghpour * fix(android): gboard shift stuck Signed-off-by: fufesou * fix(android): gboard shift stuck, remove unused param Signed-off-by: fufesou * fix(android): gboard shift stuck, release shift before sending events Signed-off-by: fufesou * chore(flutter): document stale mobile shift release flow Signed-off-by: Amirhossein Akhlaghpour --------- Signed-off-by: Amirhossein Akhlaghpour Signed-off-by: fufesou Co-authored-by: fufesou --- flutter/lib/models/input_model.dart | 72 +++++++++++ flutter/lib/models/input_modifier_utils.dart | 38 ++++++ flutter/pubspec.yaml | 4 +- flutter/test/input_modifier_utils_test.dart | 125 +++++++++++++++++++ 4 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 flutter/lib/models/input_modifier_utils.dart create mode 100644 flutter/test/input_modifier_utils_test.dart diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index ab9278217..427072677 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,6 +16,7 @@ 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'; @@ -697,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; @@ -751,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); @@ -794,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) { @@ -831,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) { 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/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, + ); + }); + }); +} From 7308c448f177c9a22d2c9300227425406ebd7fb7 Mon Sep 17 00:00:00 2001 From: Sergiusz Michalik Date: Sun, 26 Apr 2026 16:46:41 +0200 Subject: [PATCH 06/24] fix(client): serialize X11 keyboard grab and debounce focus feedback (#14836) * fix(client): serialize X11 keyboard grab and debounce focus feedback When two RustDesk sessions run fullscreen on separate monitors on Linux/X11, keyboard input gets stuck on the wrong session or stops working entirely. This happens because each Flutter isolate calls change_grab_status concurrently, racing on KEYBOARD_HOOKED and the rdev grab channel. Additionally, XGrabKeyboard causes a focus-change feedback loop: grab shifts focus away from the Flutter window, triggering PointerExit, which releases the grab, restoring focus, triggering PointerEnter, which re-grabs -- cycling at ~10 Hz and blocking keyboard input. Fix by: - Serializing grab transitions with a mutex and tracking the owning session (by lc.session_id), so a stale Wait from session A cannot clobber session B's freshly acquired grab. - Debouncing Wait events (300 ms) from the same session that just acquired the grab, breaking the X11 focus feedback loop. - Refreshing the debounce timer on idempotent Run calls (enterView while already owner), keeping the grab stable during normal use. Signed-off-by: Sergiusz Michalik * fix(client): add deferred release and dedup for debounced Wait When a Wait is debounced (within 300ms of grab acquisition), schedule a deferred release thread that re-checks after the debounce window. If no new Run refreshed the grab, the deferred thread releases it, ensuring a genuine leave within the debounce window is not lost. Add a deferred_pending flag to GrabOwnerState to prevent spawning redundant threads during the X11 focus feedback loop. Signed-off-by: Sergiusz Michalik * fix(client): use window-scoped ID and fix deferred-release re-arming Address PR review feedback: - Use per-window UUID instead of connection-scoped lc.session_id so two windows viewing the same peer get distinct grab owners - Reset deferred_pending on both idempotent Run refresh and owner handoff, so a subsequent Wait can always spawn a fresh timer - Replace manual Default impl with derive * fix(client): recover from poisoned mutex instead of panicking * docs: clarify cross-platform rationale for GrabOwnerState * fix(client): only clear deferred_pending when timer snapshot matches * fix(client): use full u128 window ID, downgrade grab logs to debug - Widen GrabOwnerState.owner to u128 to avoid theoretical collision from truncating a 128-bit UUID to 64 bits - Downgrade all grab transition log::info! to log::debug! to reduce log noise during routine window switches - Clear deferred_pending on post-debounce release path to maintain the "deferred_pending => timer in flight" invariant * fix(client): gate GRAB_DEBOUNCE_MS with cfg(target_os = "linux") * fix(grab): release grabbed keys without clobbering new owner state Signed-off-by: fufesou * fix(keyboard): Simple refactor Signed-off-by: fufesou --------- Signed-off-by: Sergiusz Michalik Signed-off-by: fufesou Co-authored-by: fufesou --- src/flutter_ffi.rs | 21 +++- src/keyboard.rs | 223 +++++++++++++++++++++++++++++++++--- src/ui_session_interface.rs | 6 +- 3 files changed, 229 insertions(+), 21 deletions(-) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 2d339f5c2..1ee13f4df 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(()) 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/ui_session_interface.rs b/src/ui_session_interface.rs index be1895e64..c18c17fe2 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 From 5b7ad339b899a17aa3bc591bf16b20ee8a84ac9d Mon Sep 17 00:00:00 2001 From: s1korrrr Date: Mon, 27 Apr 2026 13:44:35 +0200 Subject: [PATCH 07/24] fix(iPad): keep touch gestures with external mouse (#14652) * fix(ipad): keep touch gestures with external mouse Signed-off-by: Rafal * fix(mobile): touch gesture on physical mouse connected Signed-off-by: fufesou * fix(ipad): revert 9ee100b53e7a3f336122f827c814b363f7a9f9dc keep touch gestures with external mouse Signed-off-by: fufesou * fix(mobile): align view camera page with remote page Signed-off-by: fufesou --------- Signed-off-by: Rafal Signed-off-by: fufesou Co-authored-by: fufesou --- flutter/lib/common/widgets/remote_input.dart | 24 ++++++++++++++----- flutter/lib/mobile/pages/remote_page.dart | 10 ++++---- .../lib/mobile/pages/view_camera_page.dart | 12 ++++------ 3 files changed, 27 insertions(+), 19 deletions(-) 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/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 9102d163c..9064c122b 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, + ), ); }), ), 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, + ), ); }), ), From 1e6a3dc6445c3b9c0abb5fcd6454dbc6d8e154a7 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 27 Apr 2026 22:37:22 +0800 Subject: [PATCH 08/24] fix(android): waiting for image, one cause (#14919) Signed-off-by: fufesou --- .../kotlin/com/carriez/flutter_hbb/AudioRecordHandle.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 } From 99b565ef40408e1ddcb5432c206b87cddf8adef3 Mon Sep 17 00:00:00 2001 From: s1korrrr Date: Tue, 28 Apr 2026 04:55:28 +0200 Subject: [PATCH 09/24] fix(iOS): preserve local pasteboard sync from Windows hosts (#14659) * fix(ios): accept windows clipboard updates locally Signed-off-by: Rafal * docs: document clipboard text helpers * fix(iOS): sync clipboard, debug Signed-off-by: fufesou --------- Signed-off-by: Rafal Signed-off-by: fufesou Co-authored-by: fufesou --- src/client/io_loop.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 78d9a4e40..e8afa8e01 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); } From ee8cc0c06b86430ad274fdc36fbfded6ae8fb5ef Mon Sep 17 00:00:00 2001 From: eason <85663565+mango766@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:04:29 +0800 Subject: [PATCH 10/24] fix(linux): prevent X11 BadWindow crash in get_focused_display (#14561) * fix(linux): prevent X11 BadWindow crash in get_focused_display When the active window is destroyed between xdo_get_active_window and xdo_get_window_location/xdo_get_window_size calls, the default X11 error handler terminates the process with a BadWindow error. This causes the rustdesk --server process to crash and the remote session to disconnect and reconnect every time the user closes a window. Install a custom X error handler around the xdo calls that catches BadWindow errors and returns gracefully instead of crashing. Fixes: https://github.com/rustdesk/rustdesk/issues/9003 Co-Authored-By: Claude (claude-opus-4-6) Signed-off-by: easonysliu * fix(linux): prevent BadWindow crash in focus display lookup Signed-off-by: fufesou --------- Signed-off-by: easonysliu Signed-off-by: fufesou Co-authored-by: easonysliu Co-authored-by: Claude (claude-opus-4-6) Co-authored-by: fufesou --- src/platform/linux.rs | 96 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 13 deletions(-) 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) From 590296b297c7e5a718ba2c7792febd1e61a47032 Mon Sep 17 00:00:00 2001 From: Amirhosein Akhlaghpoor Date: Tue, 28 Apr 2026 07:03:41 +0000 Subject: [PATCH 11/24] fix: iPad mouse down detection for physical mouse input (#14515) * fix: iPad mouse down detection Signed-off-by: Amirhossein Akhlaghpour * fix(ipad): remove redundant check Signed-off-by: fufesou * fix(ipad): Simple refactor Signed-off-by: fufesou --------- Signed-off-by: Amirhossein Akhlaghpour Signed-off-by: fufesou Co-authored-by: fufesou --- flutter/lib/models/input_model.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 427072677..6fdffd796 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -1495,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; @@ -1507,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; } @@ -1516,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; } From bfd31d21e4cbe8e78d750f8c0a0efbd2b3b0af1b Mon Sep 17 00:00:00 2001 From: KaneBarns <44869236+KaneBarns@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:08:10 +0200 Subject: [PATCH 12/24] Update build.py (#11341) --- build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From d4a1430c27e4d07cc4e37f737a256b23eaf52cd5 Mon Sep 17 00:00:00 2001 From: orbisai0security Date: Wed, 29 Apr 2026 10:45:21 +0530 Subject: [PATCH 13/24] fix: V-002 security vulnerability (#14924) Automated security fix generated by Orbis Security AI --- libs/clipboard/src/windows/wf_cliprdr.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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; From 383a5c34781523c9b3ebdf9db39d6a19501f1847 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 2 May 2026 00:44:22 +0800 Subject: [PATCH 14/24] feat: option, enable-privacy-mode & enable-perm-change-in-accept-window (#14875) * feat: option, privacy mode Signed-off-by: fufesou * feat(privacy mode): update libs/hbb_common Signed-off-by: fufesou * feat(privacy mode): turn off on disable privacy mode Signed-off-by: fufesou * feat(privacy mode): better check if supported Signed-off-by: fufesou * feat(option): enable perm change in accept window Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/common/widgets/toolbar.dart | 36 +++++++-- flutter/lib/consts.dart | 3 + .../desktop/pages/desktop_setting_page.dart | 4 + flutter/lib/desktop/pages/server_page.dart | 41 +++++++++- .../lib/desktop/widgets/remote_toolbar.dart | 6 +- flutter/lib/mobile/pages/remote_page.dart | 3 +- flutter/lib/mobile/pages/server_page.dart | 51 ++++++++---- flutter/lib/models/server_model.dart | 20 ++++- flutter/lib/web/bridge.dart | 2 +- libs/hbb_common | 2 +- src/client/io_loop.rs | 3 + src/flutter_ffi.rs | 45 ++++++++++- src/ipc.rs | 1 + src/lang/ar.rs | 1 + src/lang/be.rs | 1 + src/lang/bg.rs | 1 + src/lang/ca.rs | 1 + src/lang/cn.rs | 1 + src/lang/cs.rs | 1 + src/lang/da.rs | 1 + src/lang/de.rs | 1 + src/lang/el.rs | 1 + src/lang/eo.rs | 1 + src/lang/es.rs | 1 + src/lang/et.rs | 1 + src/lang/eu.rs | 1 + src/lang/fa.rs | 1 + src/lang/fi.rs | 1 + src/lang/fr.rs | 1 + src/lang/ge.rs | 1 + src/lang/gu.rs | 1 + src/lang/he.rs | 1 + src/lang/hr.rs | 1 + src/lang/hu.rs | 1 + src/lang/id.rs | 1 + src/lang/it.rs | 1 + src/lang/ja.rs | 1 + src/lang/ko.rs | 1 + src/lang/kz.rs | 1 + src/lang/lt.rs | 1 + src/lang/lv.rs | 1 + src/lang/nb.rs | 1 + src/lang/nl.rs | 1 + src/lang/pl.rs | 1 + src/lang/pt_PT.rs | 1 + src/lang/ptbr.rs | 1 + src/lang/ro.rs | 1 + src/lang/ru.rs | 1 + src/lang/sc.rs | 1 + src/lang/sk.rs | 1 + src/lang/sl.rs | 1 + src/lang/sq.rs | 1 + src/lang/sr.rs | 1 + src/lang/sv.rs | 1 + src/lang/ta.rs | 1 + src/lang/template.rs | 1 + src/lang/th.rs | 1 + src/lang/tr.rs | 1 + src/lang/tw.rs | 1 + src/lang/uk.rs | 1 + src/lang/vi.rs | 1 + src/server/connection.rs | 78 +++++++++++++++++-- src/ui.rs | 6 ++ src/ui/cm.css | 11 +++ src/ui/cm.rs | 14 +++- src/ui/cm.tis | 39 ++++++++-- src/ui/header.tis | 2 +- src/ui/index.tis | 1 + src/ui/remote.tis | 2 + src/ui_cm_interface.rs | 76 ++++++++++++++++-- 70 files changed, 437 insertions(+), 57 deletions(-) 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 9064c122b..74a5af45c 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -1183,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/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/libs/hbb_common b/libs/hbb_common index 87b11a795..3e31a9493 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 87b11a795964b00deded250657a63626f2c1efa0 +Subproject commit 3e31a94939e026ab2c05d21a2c436960aa9bfea8 diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index e8afa8e01..78ba9ebc6 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1797,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); + } _ => {} } } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 1ee13f4df..3f97df078 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -972,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( @@ -1019,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) } diff --git a/src/ipc.rs b/src/ipc.rs index 099c24d34..e6d4fc834 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 { 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..7d18cd7a1 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", ""), ].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 8ad712f1e..ab6ed2e76 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Nom d’affichage"), ("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", ""), ].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..b83ee01ed 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", ""), ].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..20000cd26 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..40eb561ed 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Görünen Ad"), ("password-hidden-tip", "Şifre gizli"), ("preset-password-in-use-tip", "Önceden ayarlanmış şifre kullanılıyor"), + ("Enable privacy mode", ""), ].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/server/connection.rs b/src/server/connection.rs index 8b4eb0c48..bd5327bb2 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -241,6 +241,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 +432,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 +529,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 +679,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) => { @@ -978,7 +1023,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 +1945,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 +2221,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 { @@ -4145,6 +4192,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 +4242,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 +4261,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 +4289,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, 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..831824947 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,6 +455,13 @@ 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")))] @@ -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(), ); From 253d632709b68f3b52464ba0661f6ce1ae47fd37 Mon Sep 17 00:00:00 2001 From: solokot Date: Mon, 4 May 2026 11:49:49 +0300 Subject: [PATCH 15/24] Update ru.rs (#14947) --- src/lang/ru.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 20000cd26..3917c6fa2 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -743,6 +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", ""), + ("Enable privacy mode", "Использовать режим конфиденциальности"), ].iter().cloned().collect(); } From 52d62da00268d3a5f986b96a63f014370791a324 Mon Sep 17 00:00:00 2001 From: bilimiyorum <131397022+bilimiyorum@users.noreply.github.com> Date: Mon, 4 May 2026 11:50:23 +0300 Subject: [PATCH 16/24] Update tr.rs (#14948) 1- New string entry 2- A minor improvement for terminological consistency --- src/lang/tr.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 40eb561ed..d93ad4f68 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -741,8 +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"), - ("Enable privacy mode", ""), + ("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(); } From 5abae617dc8a5c6aea3f0c053832c1a89566d453 Mon Sep 17 00:00:00 2001 From: bovirus <1262554+bovirus@users.noreply.github.com> Date: Mon, 4 May 2026 10:50:42 +0200 Subject: [PATCH 17/24] Italian language update (#14949) --- src/lang/it.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/it.rs b/src/lang/it.rs index b83ee01ed..479551fcc 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -743,6 +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", ""), + ("Enable privacy mode", "Abilita modalità privacy"), ].iter().cloned().collect(); } From d5d0b01266edc8af6baabc2004a1096dd7088a02 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 29 Apr 2026 17:37:46 +0800 Subject: [PATCH 18/24] fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848) --- flutter/lib/common/widgets/toolbar.dart | 93 +++++++++++++++++-- flutter/lib/consts.dart | 2 + .../desktop/pages/desktop_setting_page.dart | 73 ++++++++++++++- flutter/lib/desktop/pages/remote_page.dart | 15 +++ .../lib/desktop/widgets/remote_toolbar.dart | 26 +++++- flutter/lib/mobile/pages/remote_page.dart | 13 +++ flutter/lib/mobile/pages/settings_page.dart | 19 ++++ flutter/lib/models/input_model.dart | 2 +- flutter/lib/models/model.dart | 8 ++ flutter/lib/web/bridge.dart | 25 +++++ 10 files changed, 266 insertions(+), 10 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 2e7247d95..da79c106e 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -16,16 +16,43 @@ import 'package:get/get.dart'; bool isEditOsPassword = false; +/// Action IDs that `toolbarControls` is the sole registrar for. Each call to +/// `toolbarControls` (e.g. opening the toolbar menu after a permission was +/// revoked or a state changed) wipes these so a previously-registered closure +/// can't outlive the menu entry that owns it. The for-loop at the bottom of +/// `toolbarControls` then re-registers whichever entries are still present in +/// the rebuilt menu list. +/// +/// Actions registered elsewhere — `registerSessionShortcutActions` on desktop +/// owns toggle_recording, fullscreen, switch_display, switch_tab, close_tab, +/// toggle_toolbar — MUST NOT appear here, otherwise this list would clobber +/// their registration on every menu rebuild. +/// +/// `kShortcutActionToggleRecording` is platform-conditional (mobile-only — +/// see the `!(isDesktop || isWeb)` guard in `toolbarControls`). It is handled +/// separately in the unregister pass rather than appearing in this const list. +const _kToolbarOwnedActionIds = [ + kShortcutActionSendCtrlAltDel, + kShortcutActionRestartRemote, + kShortcutActionInsertLock, + kShortcutActionToggleBlockInput, + kShortcutActionSwitchSides, + kShortcutActionRefresh, + kShortcutActionScreenshot, +]; + class TTextMenu { final Widget child; final VoidCallback? onPressed; Widget? trailingIcon; bool divider; + final String? actionId; TTextMenu( {required this.child, required this.onPressed, this.trailingIcon, - this.divider = false}); + this.divider = false, + this.actionId}); Widget getChild() { if (trailingIcon != null) { @@ -94,6 +121,20 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { final sessionId = ffi.sessionId; final isDefaultConn = ffi.connType == ConnType.defaultConn; + // Wipe everything `toolbarControls` could have registered last call so + // stale closures (e.g. for a menu entry whose permission has since been + // revoked) don't outlive the menu rebuild. See _kToolbarOwnedActionIds. + for (final actionId in _kToolbarOwnedActionIds) { + ffi.shortcutModel.unregister(actionId); + } + // toggle_recording is platform-conditional — toolbarControls only builds + // the menu entry on `!(isDesktop || isWeb)`. On desktop the registration + // is owned by `registerSessionShortcutActions` and must NOT be touched + // here. See the recording menu entry below. + if (!(isDesktop || isWeb)) { + ffi.shortcutModel.unregister(kShortcutActionToggleRecording); + } + List v = []; // elevation if (isDefaultConn && @@ -229,7 +270,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text('${translate("Insert Ctrl + Alt + Del")}'), - onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)), + onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId), + actionId: kShortcutActionSendCtrlAltDel), ); } // restart @@ -242,7 +284,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { TTextMenu( child: Text(translate('Restart remote device')), onPressed: () => - showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager)), + showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager), + actionId: kShortcutActionRestartRemote), ); } // insertLock @@ -250,7 +293,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('Insert Lock')), - onPressed: () => bind.sessionLockScreen(sessionId: sessionId)), + onPressed: () => bind.sessionLockScreen(sessionId: sessionId), + actionId: kShortcutActionInsertLock), ); } // blockUserInput @@ -268,7 +312,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { sessionId: sessionId, value: '${blockInput.value ? 'un' : ''}block-input'); blockInput.value = !blockInput.value; - })); + }, + actionId: kShortcutActionToggleBlockInput)); } // switchSides if (isDefaultConn && @@ -280,13 +325,15 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => - showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager))); + showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager), + actionId: kShortcutActionSwitchSides)); } // refresh if (pi.version.isNotEmpty) { v.add(TTextMenu( child: Text(translate('Refresh')), onPressed: () => sessionRefreshVideo(sessionId, pi), + actionId: kShortcutActionRefresh, )); } // record @@ -308,7 +355,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ) ], ), - onPressed: () => ffi.recordingModel.toggle())); + onPressed: () => ffi.recordingModel.toggle(), + actionId: kShortcutActionToggleRecording)); } // to-do: @@ -325,6 +373,14 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: ffi.ffiModel.timerScreenshot != null ? null : () { + // Live cooldown check: the menu rebuilds onPressed=null + // whenever toolbarControls runs and finds timerScreenshot + // != null, but the keyboard-shortcut callback holds onto + // the originally-enabled closure across cooldown periods + // (toolbarControls only re-runs on menu open). Without + // this guard the second shortcut press during the 30s + // cooldown still fires sessionTakeScreenshot. + if (ffi.ffiModel.timerScreenshot != null) return; if (pi.currentDisplay == kAllDisplayValue) { msgBox( sessionId, @@ -342,6 +398,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { }); } }, + actionId: kShortcutActionScreenshot, )); } } @@ -352,6 +409,28 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: () => onCopyFingerprint(FingerprintState.find(id).value), )); } + // Register tagged callbacks with the shortcut model so global keyboard + // shortcuts can dispatch the same actions as the toolbar menu items. + // + // For action IDs already cleared at the top of this function (i.e. those + // in [_kToolbarOwnedActionIds] plus the conditional toggle_recording), + // the `else` branch below is a redundant idempotent no-op — `unregister` + // just calls `Map.remove` on something already absent. + // + // The branch is kept as **defense in depth** for the case where a future + // contributor tags a menu item with an actionId that they forget to add + // to [_kToolbarOwnedActionIds]: without this `else`, the original + // "stale-closure-outlives-disabled-state" bug (e.g. Screenshot cooldown + // bypass) would silently come back for that new action only. + for (final menu in v) { + final actionId = menu.actionId; + if (actionId == null) continue; + if (menu.onPressed != null) { + ffi.shortcutModel.register(actionId, menu.onPressed!); + } else { + ffi.shortcutModel.unregister(actionId); + } + } return v; } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 832b96d24..8362ed36e 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -4,6 +4,8 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; +export 'common/widgets/keyboard_shortcuts/shortcut_constants.dart'; + const int kMaxVirtualDisplayCount = 4; const int kAllVirtualDisplay = -1; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 2841c1d27..b13b2c9cd 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -10,12 +10,14 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; +import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/plugin/manager.dart'; import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; @@ -421,11 +423,49 @@ class _GeneralState extends State<_General> { if (!isWeb) audio(context), if (!isWeb) record(context), if (!isWeb) WaylandCard(), - other() + other(), + if (!bind.isIncomingOnly()) keyboardShortcuts(), ], ).marginOnly(bottom: _kListViewBottomMargin); } + Widget keyboardShortcuts() { + // The bindings JSON (LocalConfig key `keyboard-shortcuts`) holds three + // flags + the bindings list: {enabled, pass_through, bindings}. When the + // master is off, the pass-through toggle and the Configure entry are + // hidden — both are meaningless without an active matcher. + return StatefulBuilder(builder: (context, setLocalState) { + final enabled = ShortcutModel.isEnabled(); + return _Card(title: 'Keyboard Shortcuts', children: [ + _OptionCheckBox( + context, + 'Enable keyboard shortcuts in remote session', + kShortcutLocalConfigKey, + isServer: false, + optGetter: ShortcutModel.isEnabled, + optSetter: (_, v) async { + await ShortcutModel.setEnabled(v); + setLocalState(() {}); + }, + ), + if (enabled) ...[ + _OptionCheckBox( + context, + 'Pass-through to remote', + kShortcutLocalConfigKey, + isServer: false, + optGetter: ShortcutModel.isPassThrough, + optSetter: (_, v) async { + await ShortcutModel.setPassThrough(v); + setLocalState(() {}); + }, + ), + _ShortcutsConfigureRow(), + ], + ]); + }); + } + Widget theme() { final current = MyTheme.getThemeModePreference().toShortString(); onChanged(String value) async { @@ -2950,6 +2990,37 @@ class _CountDownButtonState extends State<_CountDownButton> { } } +// Tappable row that pushes the shortcut configuration page. +class _ShortcutsConfigureRow extends StatelessWidget { + // ignore: unused_element + const _ShortcutsConfigureRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const DesktopKeyboardShortcutsPage(), + )); + }, + child: Row( + children: [ + Expanded( + child: Text(translate('Configure shortcuts...')), + ), + Icon(Icons.arrow_forward_ios, + size: 16, color: disabledTextColor(context, true)) + .marginOnly(right: 4), + ], + ).marginOnly( + left: _kCheckBoxLeftMargin, + top: 6, + bottom: 6, + ), + ); + } +} + //#endregion //#region dialogs diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 29e710bbc..944962573 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -17,6 +17,7 @@ import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; import '../../models/input_model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; @@ -126,6 +127,20 @@ class _RemotePageState extends State _ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.recordingModel .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, _ffi); + // Register the default-bound actions that `toolbarControls` doesn't + // own (fullscreen, switch display, switch tab). Done in addition, + // not instead of, the toolbar registration above. + registerSessionShortcutActions(_ffi, + tabController: widget.tabController, + toolbarState: widget.toolbarState); + } }); _ffi.canvasModel.initializeEdgeScrollFallback(this); _ffi.start( diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 5da253e80..038c264aa 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/display.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -763,8 +764,31 @@ class _ControlMenu extends StatelessWidget { if (e.divider) { return Divider(); } else { + final hint = e.actionId == null + ? null + : ShortcutDisplay.formatFor(e.actionId!); + final child = hint == null + ? e.child + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: e.child), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + hint, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ), + ], + ); return MenuButton( - child: e.child, + child: child, onPressed: e.onPressed, ffi: ffi, trailingIcon: e.trailingIcon); diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 74a5af45c..3a5256841 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -21,6 +21,7 @@ import '../../common/widgets/remote_input.dart'; import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; import '../widgets/custom_scale_widget.dart'; @@ -119,6 +120,18 @@ class _RemotePageState extends State with WidgetsBindingObserver { } _disableAndroidSoftKeyboard( isKeyboardVisible: keyboardVisibilityController.isVisible); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, gFFI); + // Mobile has no DesktopTabController, so tab-switch shortcuts + // remain unregistered (they will simply log a no-handler debug + // line if a mobile user binds one — they have no tabs to switch). + registerSessionShortcutActions(gFFI); + } }); WidgetsBinding.instance.addObserver(this); } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index 509260636..ed766cf76 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -17,8 +17,10 @@ import '../../common/widgets/login.dart'; import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../widgets/dialog.dart'; import 'home_page.dart'; +import 'mobile_keyboard_shortcuts_page.dart'; import 'scan_page.dart'; class SettingsPage extends StatefulWidget implements PageShape { @@ -819,6 +821,22 @@ class _SettingsState extends State with WidgetsBindingObserver { showThemeSettings(gFFI.dialogManager); }, ), + SettingsTile.navigation( + leading: Icon(Icons.keyboard_outlined), + title: Text(translate('Keyboard Shortcuts')), + description: Text(ShortcutModel.isEnabled() + ? translate('On') + : translate('Off')), + onPressed: (context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MobileKeyboardShortcutsPage(), + )).then((_) { + if (mounted) setState(() {}); + }); + }, + ), if (!bind.isDisableAccount()) SettingsTile.switchTile( title: Text(translate('note-at-conn-end-tip')), @@ -1352,3 +1370,4 @@ SettingsTile _getPopupDialogRadioEntry({ ), ); } + diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 6fdffd796..984d6a25c 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -346,7 +346,7 @@ class InputModel { /// 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 (!isLinux) return; if (_sideButtonChannelInitialized) return; _sideButtonChannelInitialized = true; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index e94834a2b..72ecdc99d 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -21,6 +21,7 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; @@ -476,6 +477,11 @@ class FfiModel with ChangeNotifier { } else if (name == 'exit_relative_mouse_mode') { // Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS) parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease(); + } else if (name == kShortcutEventName) { + final action = evt['action']; + if (action is String) { + parent.target?.shortcutModel.onTriggered(action); + } } else { debugPrint('Event is not handled in the fixed branch: $name'); } @@ -3623,6 +3629,7 @@ class FFI { late final ElevationModel elevationModel; // session late final CmFileModel cmFileModel; // cm late final TextureModel textureModel; //session + late final ShortcutModel shortcutModel; // session late final Peers recentPeersModel; // global late final Peers favoritePeersModel; // global late final Peers lanPeersModel; // global @@ -3652,6 +3659,7 @@ class FFI { elevationModel = ElevationModel(WeakReference(this)); cmFileModel = CmFileModel(WeakReference(this)); textureModel = TextureModel(WeakReference(this)); + shortcutModel = ShortcutModel(WeakReference(this)); recentPeersModel = Peers( name: PeersModelName.recent, loadEvent: LoadEvent.recent, diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 54e6a9a9b..f151a6e46 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import 'dart:html' as html; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/common.dart' as common; final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); @@ -930,6 +931,21 @@ class RustdeskImpl { ])); } + // Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to + // re-read its bindings from LocalStorage. Mirrors the native call which + // refreshes the Rust matcher's in-memory cache. + void mainReloadKeyboardShortcuts({dynamic hint}) { + js.context.callMethod('reloadShortcuts', []); + } + + // Web has no Rust at runtime, so the defaults seed comes from the + // [kDefaultShortcutBindings] canonical in shortcut_constants.dart. Parity + // with Rust's `default_bindings()` is enforced by tests on both sides + // against `flutter/test/fixtures/default_keyboard_shortcuts.json`. + String mainGetDefaultKeyboardShortcuts({dynamic hint}) { + return jsonEncode(kDefaultShortcutBindings); + } + String mainGetInputSource({dynamic hint}) { final inputSource = js.context.callMethod('getByName', ['option:local', 'input-source']); @@ -1176,6 +1192,15 @@ class RustdeskImpl { } Future mainInit({required String appDir, dynamic hint}) { + // JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/ + // shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a + // binding fires; route it to the active session's ShortcutModel. + // Web is single-window so `gFFI` is always the active session. + js.context['onShortcutTriggered'] = (dynamic action) { + if (action is String) { + common.gFFI.shortcutModel.onTriggered(action); + } + }; return Future.value(); } From f29dec7b13c25e2d7f1c5db4a2310522a2112836 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 6 May 2026 19:27:56 +0800 Subject: [PATCH 19/24] harden switch side --- libs/hbb_common | 2 +- src/client.rs | 77 ++++++++++++++++++++++++++++++++++--- src/client/io_loop.rs | 18 ++++++++- src/flutter_ffi.rs | 2 +- src/ipc.rs | 22 +++++++++++ src/server/connection.rs | 39 ++++++++++++++++++- src/ui_cm_interface.rs | 2 +- src/ui_session_interface.rs | 5 ++- 8 files changed, 153 insertions(+), 14 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index 3e31a9493..87b11a795 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 3e31a94939e026ab2c05d21a2c436960aa9bfea8 +Subproject commit 87b11a795964b00deded250657a63626f2c1efa0 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 78ba9ebc6..5eb7a273a 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1923,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 3f97df078..4b62b4fca 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2213,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 e6d4fc834..82b52a60c 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -285,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, @@ -771,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()); @@ -780,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/server/connection.rs b/src/server/connection.rs index bd5327bb2..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; @@ -775,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()); @@ -2579,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 @@ -3294,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, @@ -4938,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() @@ -4945,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_cm_interface.rs b/src/ui_cm_interface.rs index 831824947..cab0d7f1c 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -464,7 +464,7 @@ pub fn has_active_clients() -> bool { #[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)); diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index c18c17fe2..e6c8ac6a2 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1464,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 { From 9d1f86fbc6f5abdab7af6133abaf56003b9ad82f Mon Sep 17 00:00:00 2001 From: Mr-Update <37781396+Mr-Update@users.noreply.github.com> Date: Wed, 6 May 2026 13:32:41 +0200 Subject: [PATCH 20/24] Update de.rs (#14953) --- src/lang/de.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/de.rs b/src/lang/de.rs index 7d18cd7a1..030bc626d 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -743,6 +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", ""), + ("Enable privacy mode", "Datenschutzmodus aktivieren"), ].iter().cloned().collect(); } From 0221634a4da93c0f35a491d0ae55cbd284538d17 Mon Sep 17 00:00:00 2001 From: Lynilia <89228568+Lynilia@users.noreply.github.com> Date: Wed, 6 May 2026 13:32:59 +0200 Subject: [PATCH 21/24] Update fr.rs (#14955) --- src/lang/fr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/fr.rs b/src/lang/fr.rs index ab6ed2e76..6f7bb2880 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Nom d’affichage"), ("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", ""), + ("Enable privacy mode", "Activer le mode de confidentialité"), ].iter().cloned().collect(); } From 92509f8e8a17f07d881c4f566fc3ad6cddb3e074 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 6 May 2026 19:35:13 +0800 Subject: [PATCH 22/24] update hbb_common --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index 87b11a795..6490a8655 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 87b11a795964b00deded250657a63626f2c1efa0 +Subproject commit 6490a8655c25801e16c3b30d161d9f2b9e458b36 From 8b8a64f870c5126cef9deb9cf168ca3a6fa1e9e4 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 6 May 2026 19:40:52 +0800 Subject: [PATCH 23/24] revert hbb_common to old one --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index 6490a8655..3e31a9493 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 6490a8655c25801e16c3b30d161d9f2b9e458b36 +Subproject commit 3e31a94939e026ab2c05d21a2c436960aa9bfea8 From 5439ec38b663c2ff9de1063ac125f6ac61d78ae2 Mon Sep 17 00:00:00 2001 From: 21pages Date: Wed, 6 May 2026 20:20:17 +0800 Subject: [PATCH 24/24] Revert "fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848)" (#14973) This reverts commit d5d0b01266edc8af6baabc2004a1096dd7088a02. --- flutter/lib/common/widgets/toolbar.dart | 93 ++----------------- flutter/lib/consts.dart | 2 - .../desktop/pages/desktop_setting_page.dart | 73 +-------------- flutter/lib/desktop/pages/remote_page.dart | 15 --- .../lib/desktop/widgets/remote_toolbar.dart | 26 +----- flutter/lib/mobile/pages/remote_page.dart | 13 --- flutter/lib/mobile/pages/settings_page.dart | 19 ---- flutter/lib/models/input_model.dart | 2 +- flutter/lib/models/model.dart | 8 -- flutter/lib/web/bridge.dart | 25 ----- 10 files changed, 10 insertions(+), 266 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index da79c106e..2e7247d95 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -16,43 +16,16 @@ import 'package:get/get.dart'; bool isEditOsPassword = false; -/// Action IDs that `toolbarControls` is the sole registrar for. Each call to -/// `toolbarControls` (e.g. opening the toolbar menu after a permission was -/// revoked or a state changed) wipes these so a previously-registered closure -/// can't outlive the menu entry that owns it. The for-loop at the bottom of -/// `toolbarControls` then re-registers whichever entries are still present in -/// the rebuilt menu list. -/// -/// Actions registered elsewhere — `registerSessionShortcutActions` on desktop -/// owns toggle_recording, fullscreen, switch_display, switch_tab, close_tab, -/// toggle_toolbar — MUST NOT appear here, otherwise this list would clobber -/// their registration on every menu rebuild. -/// -/// `kShortcutActionToggleRecording` is platform-conditional (mobile-only — -/// see the `!(isDesktop || isWeb)` guard in `toolbarControls`). It is handled -/// separately in the unregister pass rather than appearing in this const list. -const _kToolbarOwnedActionIds = [ - kShortcutActionSendCtrlAltDel, - kShortcutActionRestartRemote, - kShortcutActionInsertLock, - kShortcutActionToggleBlockInput, - kShortcutActionSwitchSides, - kShortcutActionRefresh, - kShortcutActionScreenshot, -]; - class TTextMenu { final Widget child; final VoidCallback? onPressed; Widget? trailingIcon; bool divider; - final String? actionId; TTextMenu( {required this.child, required this.onPressed, this.trailingIcon, - this.divider = false, - this.actionId}); + this.divider = false}); Widget getChild() { if (trailingIcon != null) { @@ -121,20 +94,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { final sessionId = ffi.sessionId; final isDefaultConn = ffi.connType == ConnType.defaultConn; - // Wipe everything `toolbarControls` could have registered last call so - // stale closures (e.g. for a menu entry whose permission has since been - // revoked) don't outlive the menu rebuild. See _kToolbarOwnedActionIds. - for (final actionId in _kToolbarOwnedActionIds) { - ffi.shortcutModel.unregister(actionId); - } - // toggle_recording is platform-conditional — toolbarControls only builds - // the menu entry on `!(isDesktop || isWeb)`. On desktop the registration - // is owned by `registerSessionShortcutActions` and must NOT be touched - // here. See the recording menu entry below. - if (!(isDesktop || isWeb)) { - ffi.shortcutModel.unregister(kShortcutActionToggleRecording); - } - List v = []; // elevation if (isDefaultConn && @@ -270,8 +229,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text('${translate("Insert Ctrl + Alt + Del")}'), - onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId), - actionId: kShortcutActionSendCtrlAltDel), + onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)), ); } // restart @@ -284,8 +242,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { TTextMenu( child: Text(translate('Restart remote device')), onPressed: () => - showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager), - actionId: kShortcutActionRestartRemote), + showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager)), ); } // insertLock @@ -293,8 +250,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('Insert Lock')), - onPressed: () => bind.sessionLockScreen(sessionId: sessionId), - actionId: kShortcutActionInsertLock), + onPressed: () => bind.sessionLockScreen(sessionId: sessionId)), ); } // blockUserInput @@ -312,8 +268,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { sessionId: sessionId, value: '${blockInput.value ? 'un' : ''}block-input'); blockInput.value = !blockInput.value; - }, - actionId: kShortcutActionToggleBlockInput)); + })); } // switchSides if (isDefaultConn && @@ -325,15 +280,13 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => - showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager), - actionId: kShortcutActionSwitchSides)); + showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager))); } // refresh if (pi.version.isNotEmpty) { v.add(TTextMenu( child: Text(translate('Refresh')), onPressed: () => sessionRefreshVideo(sessionId, pi), - actionId: kShortcutActionRefresh, )); } // record @@ -355,8 +308,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ) ], ), - onPressed: () => ffi.recordingModel.toggle(), - actionId: kShortcutActionToggleRecording)); + onPressed: () => ffi.recordingModel.toggle())); } // to-do: @@ -373,14 +325,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: ffi.ffiModel.timerScreenshot != null ? null : () { - // Live cooldown check: the menu rebuilds onPressed=null - // whenever toolbarControls runs and finds timerScreenshot - // != null, but the keyboard-shortcut callback holds onto - // the originally-enabled closure across cooldown periods - // (toolbarControls only re-runs on menu open). Without - // this guard the second shortcut press during the 30s - // cooldown still fires sessionTakeScreenshot. - if (ffi.ffiModel.timerScreenshot != null) return; if (pi.currentDisplay == kAllDisplayValue) { msgBox( sessionId, @@ -398,7 +342,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { }); } }, - actionId: kShortcutActionScreenshot, )); } } @@ -409,28 +352,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: () => onCopyFingerprint(FingerprintState.find(id).value), )); } - // Register tagged callbacks with the shortcut model so global keyboard - // shortcuts can dispatch the same actions as the toolbar menu items. - // - // For action IDs already cleared at the top of this function (i.e. those - // in [_kToolbarOwnedActionIds] plus the conditional toggle_recording), - // the `else` branch below is a redundant idempotent no-op — `unregister` - // just calls `Map.remove` on something already absent. - // - // The branch is kept as **defense in depth** for the case where a future - // contributor tags a menu item with an actionId that they forget to add - // to [_kToolbarOwnedActionIds]: without this `else`, the original - // "stale-closure-outlives-disabled-state" bug (e.g. Screenshot cooldown - // bypass) would silently come back for that new action only. - for (final menu in v) { - final actionId = menu.actionId; - if (actionId == null) continue; - if (menu.onPressed != null) { - ffi.shortcutModel.register(actionId, menu.onPressed!); - } else { - ffi.shortcutModel.unregister(actionId); - } - } return v; } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 8362ed36e..832b96d24 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -4,8 +4,6 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; -export 'common/widgets/keyboard_shortcuts/shortcut_constants.dart'; - const int kMaxVirtualDisplayCount = 4; const int kAllVirtualDisplay = -1; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index b13b2c9cd..2841c1d27 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -10,14 +10,12 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; -import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; -import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/plugin/manager.dart'; import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; @@ -423,49 +421,11 @@ class _GeneralState extends State<_General> { if (!isWeb) audio(context), if (!isWeb) record(context), if (!isWeb) WaylandCard(), - other(), - if (!bind.isIncomingOnly()) keyboardShortcuts(), + other() ], ).marginOnly(bottom: _kListViewBottomMargin); } - Widget keyboardShortcuts() { - // The bindings JSON (LocalConfig key `keyboard-shortcuts`) holds three - // flags + the bindings list: {enabled, pass_through, bindings}. When the - // master is off, the pass-through toggle and the Configure entry are - // hidden — both are meaningless without an active matcher. - return StatefulBuilder(builder: (context, setLocalState) { - final enabled = ShortcutModel.isEnabled(); - return _Card(title: 'Keyboard Shortcuts', children: [ - _OptionCheckBox( - context, - 'Enable keyboard shortcuts in remote session', - kShortcutLocalConfigKey, - isServer: false, - optGetter: ShortcutModel.isEnabled, - optSetter: (_, v) async { - await ShortcutModel.setEnabled(v); - setLocalState(() {}); - }, - ), - if (enabled) ...[ - _OptionCheckBox( - context, - 'Pass-through to remote', - kShortcutLocalConfigKey, - isServer: false, - optGetter: ShortcutModel.isPassThrough, - optSetter: (_, v) async { - await ShortcutModel.setPassThrough(v); - setLocalState(() {}); - }, - ), - _ShortcutsConfigureRow(), - ], - ]); - }); - } - Widget theme() { final current = MyTheme.getThemeModePreference().toShortString(); onChanged(String value) async { @@ -2990,37 +2950,6 @@ class _CountDownButtonState extends State<_CountDownButton> { } } -// Tappable row that pushes the shortcut configuration page. -class _ShortcutsConfigureRow extends StatelessWidget { - // ignore: unused_element - const _ShortcutsConfigureRow({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const DesktopKeyboardShortcutsPage(), - )); - }, - child: Row( - children: [ - Expanded( - child: Text(translate('Configure shortcuts...')), - ), - Icon(Icons.arrow_forward_ios, - size: 16, color: disabledTextColor(context, true)) - .marginOnly(right: 4), - ], - ).marginOnly( - left: _kCheckBoxLeftMargin, - top: 6, - bottom: 6, - ), - ); - } -} - //#endregion //#region dialogs diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 944962573..29e710bbc 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -17,7 +17,6 @@ import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; import '../../models/input_model.dart'; import '../../models/platform_model.dart'; -import '../../models/shortcut_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; @@ -127,20 +126,6 @@ class _RemotePageState extends State _ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.recordingModel .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); - // Seed shortcut action callbacks once the session is ready, so that - // global keyboard shortcuts work even if the user never opens the - // toolbar menu. The returned list is intentionally discarded — the - // side effect of registering callbacks (inside toolbarControls) is - // what we want here. - if (mounted) { - toolbarControls(context, widget.id, _ffi); - // Register the default-bound actions that `toolbarControls` doesn't - // own (fullscreen, switch display, switch tab). Done in addition, - // not instead of, the toolbar registration above. - registerSessionShortcutActions(_ffi, - tabController: widget.tabController, - toolbarState: widget.toolbarState); - } }); _ffi.canvasModel.initializeEdgeScrollFallback(this); _ffi.start( diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 038c264aa..5da253e80 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; -import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/display.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -764,31 +763,8 @@ class _ControlMenu extends StatelessWidget { if (e.divider) { return Divider(); } else { - final hint = e.actionId == null - ? null - : ShortcutDisplay.formatFor(e.actionId!); - final child = hint == null - ? e.child - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: e.child), - Padding( - padding: const EdgeInsets.only(left: 16), - child: Text( - hint, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: Theme.of(context).hintColor, - ), - ), - ), - ], - ); return MenuButton( - child: child, + child: e.child, onPressed: e.onPressed, ffi: ffi, trailingIcon: e.trailingIcon); diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 3a5256841..74a5af45c 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -21,7 +21,6 @@ import '../../common/widgets/remote_input.dart'; import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; -import '../../models/shortcut_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; import '../widgets/custom_scale_widget.dart'; @@ -120,18 +119,6 @@ class _RemotePageState extends State with WidgetsBindingObserver { } _disableAndroidSoftKeyboard( isKeyboardVisible: keyboardVisibilityController.isVisible); - // Seed shortcut action callbacks once the session is ready, so that - // global keyboard shortcuts work even if the user never opens the - // toolbar menu. The returned list is intentionally discarded — the - // side effect of registering callbacks (inside toolbarControls) is - // what we want here. - if (mounted) { - toolbarControls(context, widget.id, gFFI); - // Mobile has no DesktopTabController, so tab-switch shortcuts - // remain unregistered (they will simply log a no-handler debug - // line if a mobile user binds one — they have no tabs to switch). - registerSessionShortcutActions(gFFI); - } }); WidgetsBinding.instance.addObserver(this); } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index ed766cf76..509260636 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -17,10 +17,8 @@ import '../../common/widgets/login.dart'; import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; -import '../../models/shortcut_model.dart'; import '../widgets/dialog.dart'; import 'home_page.dart'; -import 'mobile_keyboard_shortcuts_page.dart'; import 'scan_page.dart'; class SettingsPage extends StatefulWidget implements PageShape { @@ -821,22 +819,6 @@ class _SettingsState extends State with WidgetsBindingObserver { showThemeSettings(gFFI.dialogManager); }, ), - SettingsTile.navigation( - leading: Icon(Icons.keyboard_outlined), - title: Text(translate('Keyboard Shortcuts')), - description: Text(ShortcutModel.isEnabled() - ? translate('On') - : translate('Off')), - onPressed: (context) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const MobileKeyboardShortcutsPage(), - )).then((_) { - if (mounted) setState(() {}); - }); - }, - ), if (!bind.isDisableAccount()) SettingsTile.switchTile( title: Text(translate('note-at-conn-end-tip')), @@ -1370,4 +1352,3 @@ SettingsTile _getPopupDialogRadioEntry({ ), ); } - diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 984d6a25c..6fdffd796 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -346,7 +346,7 @@ class InputModel { /// which runs per-engine, so each isolate registers its own handler tied /// to its own set of InputModels. static void initSideButtonChannel() { - if (!isLinux) return; + if (!Platform.isLinux) return; if (_sideButtonChannelInitialized) return; _sideButtonChannelInitialized = true; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 72ecdc99d..e94834a2b 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -21,7 +21,6 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; -import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; @@ -477,11 +476,6 @@ class FfiModel with ChangeNotifier { } else if (name == 'exit_relative_mouse_mode') { // Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS) parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease(); - } else if (name == kShortcutEventName) { - final action = evt['action']; - if (action is String) { - parent.target?.shortcutModel.onTriggered(action); - } } else { debugPrint('Event is not handled in the fixed branch: $name'); } @@ -3629,7 +3623,6 @@ class FFI { late final ElevationModel elevationModel; // session late final CmFileModel cmFileModel; // cm late final TextureModel textureModel; //session - late final ShortcutModel shortcutModel; // session late final Peers recentPeersModel; // global late final Peers favoritePeersModel; // global late final Peers lanPeersModel; // global @@ -3659,7 +3652,6 @@ class FFI { elevationModel = ElevationModel(WeakReference(this)); cmFileModel = CmFileModel(WeakReference(this)); textureModel = TextureModel(WeakReference(this)); - shortcutModel = ShortcutModel(WeakReference(this)); recentPeersModel = Peers( name: PeersModelName.recent, loadEvent: LoadEvent.recent, diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index f151a6e46..54e6a9a9b 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -7,7 +7,6 @@ import 'package:uuid/uuid.dart'; import 'dart:html' as html; import 'package:flutter_hbb/consts.dart'; -import 'package:flutter_hbb/common.dart' as common; final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); @@ -931,21 +930,6 @@ class RustdeskImpl { ])); } - // Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to - // re-read its bindings from LocalStorage. Mirrors the native call which - // refreshes the Rust matcher's in-memory cache. - void mainReloadKeyboardShortcuts({dynamic hint}) { - js.context.callMethod('reloadShortcuts', []); - } - - // Web has no Rust at runtime, so the defaults seed comes from the - // [kDefaultShortcutBindings] canonical in shortcut_constants.dart. Parity - // with Rust's `default_bindings()` is enforced by tests on both sides - // against `flutter/test/fixtures/default_keyboard_shortcuts.json`. - String mainGetDefaultKeyboardShortcuts({dynamic hint}) { - return jsonEncode(kDefaultShortcutBindings); - } - String mainGetInputSource({dynamic hint}) { final inputSource = js.context.callMethod('getByName', ['option:local', 'input-source']); @@ -1192,15 +1176,6 @@ class RustdeskImpl { } Future mainInit({required String appDir, dynamic hint}) { - // JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/ - // shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a - // binding fires; route it to the active session's ShortcutModel. - // Web is single-window so `gFFI` is always the active session. - js.context['onShortcutTriggered'] = (dynamic action) { - if (action is String) { - common.gFFI.shortcutModel.onTriggered(action); - } - }; return Future.value(); }