From 04faf21c78cfd99b50c4d6bffbeefbf98986c5ea Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 28 Apr 2026 14:28:02 +0800 Subject: [PATCH 01/36] feat: keyboard shortcuts in remote sessions Add an opt-in keyboard-shortcut system that triggers session actions (Send Ctrl+Alt+Del, Toggle Fullscreen, Switch Display, Screenshot, Switch Tab, etc.) via three-modifier combinations during a remote session. Architecture - Native: src/keyboard/shortcuts.rs intercepts at the encoder layer (process_event and process_event_with_session), so the feature is input-source-independent. Bindings persist as a single JSON blob in LocalConfig. - Web: matching + keydown intercept live in the separate hand- written TS client at flutter/web/js/ (gitignored, not in this repo). flutter/lib/web/bridge.dart::mainInit registers window.onShortcutTriggered so the JS matcher can dispatch back into the active session's ShortcutModel; the bridge's mainReloadKeyboardShortcuts forwards to a JS reloadShortcuts on settings writes. - Three-modifier prefix (Ctrl+Alt+Shift; Cmd+Option+Shift on macOS/iOS) sidesteps the need for a pass-through toggle. - Flutter native path threads the explicit per-call SessionID for tab-precise routing; rdev path uses globally-current session. UI - Settings -> General -> Keyboard Shortcuts opens a dedicated configuration page; desktop and mobile share a body widget. - Recording dialog with live capture, prefix validation, and a conflict-replace flow. - Toolbar menu items display the bound shortcut inline. - Default bindings (adapted from AnyDesk): +Del Send Ctrl+Alt+Del +Enter Toggle Fullscreen +Left/Right Switch Display Prev/Next +P Screenshot +1..9 Switch Session Tab Other - AGENTS.md: documented (a) flutter_rust_bridge_codegen needs a pinned version + Dart bridge wrappers should be hand- written, and (b) the Web-target split where flutter/web/js/ is the runtime owner on Web rather than wasm-compiled Rust. - 38 new i18n strings in src/lang/en.rs with Chinese translations in src/lang/cn.rs. Refs discussion #1933. --- .gitignore | 4 +- AGENTS.md | 24 + .../widgets/keyboard_shortcuts/display.dart | 65 +++ .../widgets/keyboard_shortcuts/page_body.dart | 490 ++++++++++++++++++ .../keyboard_shortcuts/recording_dialog.dart | 371 +++++++++++++ flutter/lib/common/widgets/toolbar.dart | 28 +- flutter/lib/consts.dart | 21 + .../desktop_keyboard_shortcuts_page.dart | 58 +++ .../desktop/pages/desktop_setting_page.dart | 81 ++- flutter/lib/desktop/pages/remote_page.dart | 14 + .../lib/desktop/widgets/remote_toolbar.dart | 26 +- .../pages/mobile_keyboard_shortcuts_page.dart | 95 ++++ 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/models/shortcut_model.dart | 141 +++++ flutter/lib/web/bridge.dart | 34 ++ src/flutter_ffi.rs | 14 + src/keyboard.rs | 56 ++ src/keyboard/shortcuts.rs | 370 +++++++++++++ src/lang/cn.rs | 39 ++ src/lang/en.rs | 42 ++ src/ui_session_interface.rs | 25 +- 24 files changed, 2028 insertions(+), 12 deletions(-) create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/display.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart create mode 100644 flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart create mode 100644 flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart create mode 100644 flutter/lib/models/shortcut_model.dart create mode 100644 src/keyboard/shortcuts.rs diff --git a/.gitignore b/.gitignore index d2e09a906..9ce4b5bb3 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ +# Local git worktrees +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index e36c65fab..abb022286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,30 @@ * Use `spawn_blocking` or dedicated threads for blocking work. * Do not use `std::thread::sleep()` in async code. +## Flutter Rust Bridge + +* Do **not** run `flutter_rust_bridge_codegen` — it requires a specific pinned version that is not easy to set up locally. +* When adding new FFI functions in `src/flutter_ffi.rs`, hand-write the corresponding Dart wrappers instead of regenerating. +* Web bridge (committed): edit `flutter/lib/web/bridge.dart` directly. Follow the existing patterns there for `SyncReturn` / `Future` and the `dart:js` glue. +* Native bridge (`flutter/lib/generated_bridge.dart`, `src/bridge_generated.rs`, `src/bridge_generated.io.rs`): these are gitignored and regenerated by the project's CI codegen. Manually editing them locally is fine for development testing, but those edits do not persist into commits. + +## Web (Flutter Web) Architecture + +Flutter Web in this repo is **not** "Dart compiled to JS via Flutter alone". The runtime is split: + +* **Native targets (Win/Mac/Linux/Android/iOS)**: Rust drives sessions via `flutter_rust_bridge`; Dart only renders UI. +* **Web target**: Rust does **not** run. There is a separate hand-written TypeScript / JavaScript client at `flutter/web/js/` (gitignored — not present in this repo, lives in the maintainer's local tree). It owns connection, codec, keyboard, clipboard, etc. — basically a JS port of the Rust client. The Dart UI talks to it through `flutter/lib/web/bridge.dart`, which uses `dart:js` to call JS-side functions and to register Dart-side callbacks on `window.*`. + +Implications when adding any session-runtime feature (keyboard, clipboard, audio, …): + +* The Rust implementation in `src/` is for **native only**. Don't try to compile it to wasm. +* The matching Web-side logic must be written in TS/JS under `flutter/web/js/src/`. It's a translation of the Rust logic, usually simpler — Web is single-window, so any per-session-id plumbing in Rust collapses to a single global on Web. +* `flutter/lib/web/bridge.dart` is the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes through `bind`. Don't sprinkle `if (isWeb)` runtime branches in shared Dart files to call Web-specific logic — put the platform divergence in the bridge. +* For JS → Dart events (e.g., a Web matcher firing), the convention is: Dart sets `js.context['onFooBar'] = (...) {...}` once at startup (typically in `mainInit`); the JS side calls `window.onFooBar(...)`. See `onLoadAbFinished`, `onLoadGroupFinished` for reference. +* The maintainer cannot easily run `flutter_rust_bridge_codegen`, so when a new FFI function lands in `src/flutter_ffi.rs`: + 1. add the Web counterpart to `flutter/lib/web/bridge.dart` by hand; + 2. note that on the Web target it may need to be a no-op or a JS bridge call rather than a real Rust invocation. + ## Editing Hygiene * Change only what is required. diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/display.dart b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart new file mode 100644 index 000000000..a90dfdcd8 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart @@ -0,0 +1,65 @@ +// flutter/lib/common/widgets/keyboard_shortcuts/display.dart +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import '../../../consts.dart'; +import '../../../models/platform_model.dart'; + +/// Read the bindings JSON and produce a human-readable shortcut string for +/// `actionId`, formatted for the current OS. Returns null if unbound. +class ShortcutDisplay { + static String? formatFor(String actionId) { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return null; + final Map parsed; + try { + parsed = jsonDecode(raw) as Map; + } catch (_) { + return null; + } + if (parsed['enabled'] != true) return null; + final list = (parsed['bindings'] as List? ?? []).cast>(); + final found = list.firstWhere( + (b) => b['action'] == actionId, + orElse: () => {}, + ); + if (found.isEmpty) return null; + + // Guard against a hand-edited / corrupt config where `key` is missing or + // not a string — silently treat the binding as unbound rather than + // crashing the toolbar render. + final keyValue = found['key']; + if (keyValue is! String) return null; + + final isMac = defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS; + // `mods` similarly may be malformed; treat a non-list as no modifiers. + final modsRaw = found['mods']; + final mods = modsRaw is List + ? modsRaw.whereType().toList() + : const []; + final parts = []; + for (final m in ['primary', 'alt', 'shift']) { + if (!mods.contains(m)) continue; + switch (m) { + case 'primary': parts.add(isMac ? '⌘' : 'Ctrl'); break; + case 'alt': parts.add(isMac ? '⌥' : 'Alt'); break; + case 'shift': parts.add(isMac ? '⇧' : 'Shift'); break; + } + } + parts.add(_keyDisplay(keyValue, isMac)); + return isMac ? parts.join('') : parts.join('+'); + } + + static String _keyDisplay(String key, bool isMac) { + switch (key) { + case 'delete': return isMac ? '⌫' : 'Del'; + case 'enter': return isMac ? '⏎' : 'Enter'; + case 'arrow_left': return '←'; + case 'arrow_right':return '→'; + case 'arrow_up': return '↑'; + case 'arrow_down': return '↓'; + } + if (key.startsWith('digit')) return key.substring(5); + return key.toUpperCase(); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart new file mode 100644 index 000000000..9b9e7afb4 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart @@ -0,0 +1,490 @@ +// flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart +// +// Shared body widget for the Keyboard Shortcuts configuration page. Both the +// desktop (`desktop/pages/desktop_keyboard_shortcuts_page.dart`) and mobile +// (`mobile/pages/mobile_keyboard_shortcuts_page.dart`) pages render this +// widget inside their own platform-styled Scaffold + AppBar shell. +// +// The body owns: +// * the top-level enable/disable toggle (mirrors the General-tab toggle — +// same JSON key, same semantics); +// * a grouped list of actions, each with its current binding plus +// edit / clear icons; +// * the JSON read/write helpers under [kShortcutLocalConfigKey] in the +// canonical {enabled, bindings:[{action,mods,key}]} shape; +// * the recording-dialog round-trip and conflict-replace bookkeeping; +// * "Reset to defaults" (called from the platform AppBar). +// +// Platform shells supply only: +// * the AppBar (with a "Reset to defaults" action that calls +// [KeyboardShortcutsPageBodyState.resetToDefaultsWithConfirm]); +// * surrounding padding / list-tile vs. dense-row visuals via the +// [compact] flag. + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../common.dart'; +import '../../../consts.dart'; +import '../../../models/platform_model.dart'; +import '../../../models/shortcut_model.dart'; +import 'recording_dialog.dart'; + +/// One configurable action — id + i18n key for its label. +class KeyboardShortcutActionEntry { + final String id; + final String labelKey; + const KeyboardShortcutActionEntry(this.id, this.labelKey); +} + +/// A named group of actions (e.g. "Session Control"). +class KeyboardShortcutActionGroup { + final String titleKey; + final List actions; + const KeyboardShortcutActionGroup(this.titleKey, this.actions); +} + +/// Canonical action group definitions used by both the desktop and mobile +/// configuration pages. The order of groups and entries here is the order +/// the user sees in the UI. (Not `const` because the per-tab ids come from +/// the `kShortcutActionSwitchTab(n)` helper in `consts.dart`.) +final List kKeyboardShortcutActionGroups = [ + KeyboardShortcutActionGroup('Session Control', [ + KeyboardShortcutActionEntry( + kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'), + KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'), + KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'), + KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'), + KeyboardShortcutActionEntry( + kShortcutActionToggleRecording, 'Toggle Recording'), + KeyboardShortcutActionEntry( + kShortcutActionToggleBlockInput, 'Toggle Block User Input'), + ]), + KeyboardShortcutActionGroup('Display', [ + KeyboardShortcutActionEntry( + kShortcutActionToggleFullscreen, 'Toggle Fullscreen'), + KeyboardShortcutActionEntry( + kShortcutActionSwitchDisplayNext, 'Switch to next display'), + KeyboardShortcutActionEntry( + kShortcutActionSwitchDisplayPrev, 'Switch to previous display'), + KeyboardShortcutActionEntry(kShortcutActionViewMode1to1, 'View Mode 1:1'), + KeyboardShortcutActionEntry( + kShortcutActionViewModeShrink, 'View Mode Shrink'), + KeyboardShortcutActionEntry( + kShortcutActionViewModeStretch, 'View Mode Stretch'), + ]), + KeyboardShortcutActionGroup('Other', [ + KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take Screenshot'), + KeyboardShortcutActionEntry(kShortcutActionToggleAudio, 'Toggle Audio'), + KeyboardShortcutActionEntry( + kShortcutActionTogglePrivacyMode, 'Toggle Privacy Mode'), + for (var n = 1; n <= 9; n++) + KeyboardShortcutActionEntry( + kShortcutActionSwitchTab(n), 'Switch Tab $n'), + ]), +]; + +/// The shared body widget. Render this inside a platform-styled Scaffold. +/// +/// [compact] toggles the desktop dense-row layout (`true`) versus the mobile +/// touch-friendly ListTile layout (`false`). +/// +/// [editButtonHint] is shown as the tooltip on the Edit icon. Mobile shells +/// use this to clarify that recording requires a physical keyboard. +/// +/// [headerBanner] is an optional widget rendered above the toggle. Mobile +/// uses this to show the "Recording requires a physical keyboard" hint. +class KeyboardShortcutsPageBody extends StatefulWidget { + final bool compact; + final String? editButtonHint; + final Widget? headerBanner; + + const KeyboardShortcutsPageBody({ + Key? key, + this.compact = true, + this.editButtonHint, + this.headerBanner, + }) : super(key: key); + + @override + State createState() => + KeyboardShortcutsPageBodyState(); +} + +/// Public state so platform shells can call [resetToDefaultsWithConfirm] from +/// their AppBar action. +class KeyboardShortcutsPageBodyState extends State { + // ----- Persistence helpers ----- + + Map _readJson() { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return {'enabled': false, 'bindings': []}; + try { + final parsed = jsonDecode(raw) as Map; + parsed['bindings'] ??= []; + parsed['enabled'] ??= false; + return parsed; + } catch (_) { + return {'enabled': false, 'bindings': []}; + } + } + + Future _writeJson(Map json) async { + await bind.mainSetLocalOption( + key: kShortcutLocalConfigKey, value: jsonEncode(json)); + // Refresh the matcher cache so writes take effect immediately. On native + // this hits the Rust matcher; on Web the bridge forwards to the JS-side + // matcher in flutter/web/js/. + bind.mainReloadKeyboardShortcuts(); + if (mounted) setState(() {}); + } + + /// Replace the bindings entry for [actionId] with [binding]. If [binding] + /// is null, removes the existing entry. If the user is replacing a + /// conflicting binding, [clearActionId] points at the action whose + /// (now-stale) binding should be removed in the same write. + Future _setBinding( + String actionId, { + Map? binding, + String? clearActionId, + }) async { + final json = _readJson(); + final list = ((json['bindings'] as List?) ?? []) + .cast>() + .toList(); + list.removeWhere((b) { + final a = b['action']; + return a == actionId || (clearActionId != null && a == clearActionId); + }); + if (binding != null) { + list.add(binding); + } + json['bindings'] = list; + await _writeJson(json); + } + + Future _setEnabled(bool v) async { + final json = _readJson(); + json['enabled'] = v; + // First-time enable: seed defaults if the user has never bound anything. + final list = (json['bindings'] as List?) ?? const []; + if (v && list.isEmpty) { + json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts()); + } + await _writeJson(json); + } + + Future _resetToDefaults() async { + final json = _readJson(); + json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts()); + await _writeJson(json); + } + + String _labelFor(String actionId) { + for (final g in kKeyboardShortcutActionGroups) { + for (final a in g.actions) { + if (a.id == actionId) return translate(a.labelKey); + } + } + return actionId; + } + + // ----- UI handlers ----- + + Future _onEdit(KeyboardShortcutActionEntry entry) async { + final json = _readJson(); + final bindings = ((json['bindings'] as List?) ?? []) + .cast>(); + final result = await showRecordingDialog( + context: context, + actionId: entry.id, + actionLabel: translate(entry.labelKey), + existingBindings: bindings, + actionLabelLookup: _labelFor, + ); + if (result == null) return; + await _setBinding( + entry.id, + binding: result.binding, + clearActionId: result.clearActionId, + ); + } + + Future _onClear(KeyboardShortcutActionEntry entry) async { + await _setBinding(entry.id, binding: null); + } + + /// Public — invoked from the platform AppBar action. + Future resetToDefaultsWithConfirm() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(translate('Reset to defaults')), + content: Text(translate('shortcut-reset-confirm-tip')), + actions: [ + dialogButton('Cancel', + onPressed: () => Navigator.of(ctx).pop(false), + isOutline: true), + dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)), + ], + ), + ); + if (confirmed == true) { + await _resetToDefaults(); + } + } + + // ----- Build ----- + + @override + Widget build(BuildContext context) { + final enabled = ShortcutModel.isEnabled(); + final theme = Theme.of(context); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (widget.headerBanner != null) ...[ + widget.headerBanner!, + const SizedBox(height: 12), + ], + // Top toggle — mirrors the General-tab _OptionCheckBox semantics. + Row( + children: [ + Checkbox( + value: enabled, + onChanged: (v) async { + if (v == null) return; + await _setEnabled(v); + }, + ), + const SizedBox(width: 4), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _setEnabled(!enabled), + child: Text( + translate('Enable keyboard shortcuts in remote session'), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + translate('shortcut-page-description'), + style: TextStyle(color: theme.hintColor), + ), + ), + const SizedBox(height: 16), + // Disabled visual state when toggle is off — but still scrollable. + Opacity( + opacity: enabled ? 1.0 : 0.5, + child: AbsorbPointer( + absorbing: !enabled, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final group in kKeyboardShortcutActionGroups) + _buildGroup(context, group), + ], + ), + ), + ), + ], + ); + } + + Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Text( + translate(group.titleKey), + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 8), + const Expanded( + child: Divider(thickness: 1), + ), + ], + ), + ), + const SizedBox(height: 4), + for (final action in group.actions) + widget.compact + ? _buildCompactRow(context, action) + : _buildTouchRow(context, action), + ], + ); + } + + /// Desktop dense row: label | shortcut | edit | clear, all in one Row. + Widget _buildCompactRow( + BuildContext context, KeyboardShortcutActionEntry entry) { + final shortcut = ShortcutDisplayForActionId.format(entry.id); + final hasBinding = shortcut != null; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Expanded( + flex: 5, + child: Text(translate(entry.labelKey)), + ), + Expanded( + flex: 4, + child: Text( + shortcut ?? '—', + style: TextStyle( + fontFamily: defaultTargetPlatform == TargetPlatform.windows + ? 'Consolas' + : 'monospace', + color: hasBinding ? null : Theme.of(context).hintColor, + ), + ), + ), + IconButton( + tooltip: widget.editButtonHint ?? translate('Edit'), + onPressed: () => _onEdit(entry), + icon: const Icon(Icons.edit_outlined, size: 18), + ), + SizedBox( + width: 40, + child: hasBinding + ? IconButton( + tooltip: translate('Clear'), + onPressed: () => _onClear(entry), + icon: const Icon(Icons.close, size: 18), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + /// Mobile touch row: ListTile with title + subtitle + trailing icons. + Widget _buildTouchRow( + BuildContext context, KeyboardShortcutActionEntry entry) { + final shortcut = ShortcutDisplayForActionId.format(entry.id); + final hasBinding = shortcut != null; + return ListTile( + dense: false, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + title: Text(translate(entry.labelKey)), + subtitle: Text( + shortcut ?? '—', + style: TextStyle( + fontFamily: defaultTargetPlatform == TargetPlatform.windows + ? 'Consolas' + : 'monospace', + color: hasBinding ? null : Theme.of(context).hintColor, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: widget.editButtonHint ?? translate('Edit'), + onPressed: () => _onEdit(entry), + icon: const Icon(Icons.edit_outlined), + ), + if (hasBinding) + IconButton( + tooltip: translate('Clear'), + onPressed: () => _onClear(entry), + icon: const Icon(Icons.close), + ) + else + const SizedBox(width: 48), + ], + ), + ); + } +} + +/// Thin wrapper around [ShortcutDisplay.formatFor] that ignores the +/// `enabled` flag so the configuration page can always show the user what +/// they have bound, even when the feature is currently disabled. +class ShortcutDisplayForActionId { + static String? format(String actionId) { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return null; + final Map parsed; + try { + parsed = jsonDecode(raw) as Map; + } catch (_) { + return null; + } + final list = (parsed['bindings'] as List? ?? const []) + .cast>(); + final found = list.firstWhere( + (b) => b['action'] == actionId, + orElse: () => {}, + ); + if (found.isEmpty) return null; + + // Guard against a hand-edited / corrupt config where `key` is missing or + // not a string — render the row as unbound instead of crashing the + // settings page. + final keyValue = found['key']; + if (keyValue is! String) return null; + + final isMac = defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS; + // `mods` similarly may be malformed; treat a non-list as no modifiers. + final modsRaw = found['mods']; + final mods = modsRaw is List + ? modsRaw.whereType().toList() + : const []; + final parts = []; + for (final m in ['primary', 'alt', 'shift']) { + if (!mods.contains(m)) continue; + switch (m) { + case 'primary': + parts.add(isMac ? '⌘' : 'Ctrl'); + break; + case 'alt': + parts.add(isMac ? '⌥' : 'Alt'); + break; + case 'shift': + parts.add(isMac ? '⇧' : 'Shift'); + break; + } + } + parts.add(_keyDisplay(keyValue, isMac)); + return isMac ? parts.join('') : parts.join('+'); + } + + static String _keyDisplay(String key, bool isMac) { + switch (key) { + case 'delete': + return isMac ? '⌫' : 'Del'; + case 'enter': + return isMac ? '⏎' : 'Enter'; + case 'arrow_left': + return '←'; + case 'arrow_right': + return '→'; + case 'arrow_up': + return '↑'; + case 'arrow_down': + return '↓'; + } + if (key.startsWith('digit')) return key.substring(5); + return key.toUpperCase(); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart new file mode 100644 index 000000000..62d3431f9 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart @@ -0,0 +1,371 @@ +// flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart +// +// Modal dialog used by the Keyboard Shortcuts settings page to capture a new +// key combination for a given action. The dialog listens for KeyDown events, +// extracts the modifier set + non-modifier key, validates against the +// "must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS)" rule, and reports +// any conflict with another already-bound action. +// +// On Save, returns the new binding map ({action, mods, key}) plus the +// optional id of the action whose binding should be cleared (the conflict +// "Replace" path). On Cancel, returns null. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../common.dart'; + +/// Result of the recording dialog. +class RecordingResult { + /// The new binding map to write: {action, mods, key}. + final Map binding; + + /// If the chosen combo conflicted with another action, the user chose + /// "Replace" — the caller must clear this action's binding before writing + /// the new one. + final String? clearActionId; + + RecordingResult(this.binding, this.clearActionId); +} + +/// Show the recording dialog. +/// +/// [actionId] is the action being edited (used for the title and to detect +/// "binding to itself" — that's not a conflict). +/// [actionLabel] is the translated, user-facing action name. +/// [existingBindings] is the current bindings list (used for conflict detection). +/// [actionLabelLookup] resolves an actionId to its translated label, used in +/// the conflict warning. +Future showRecordingDialog({ + required BuildContext context, + required String actionId, + required String actionLabel, + required List> existingBindings, + required String Function(String) actionLabelLookup, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _RecordingDialog( + actionId: actionId, + actionLabel: actionLabel, + existingBindings: existingBindings, + actionLabelLookup: actionLabelLookup, + ), + ); +} + +class _RecordingDialog extends StatefulWidget { + final String actionId; + final String actionLabel; + final List> existingBindings; + final String Function(String) actionLabelLookup; + + const _RecordingDialog({ + required this.actionId, + required this.actionLabel, + required this.existingBindings, + required this.actionLabelLookup, + }); + + @override + State<_RecordingDialog> createState() => _RecordingDialogState(); +} + +class _RecordingDialogState extends State<_RecordingDialog> { + final FocusNode _focusNode = FocusNode(); + + // Captured combo. null until the user presses something with a non-modifier. + Set _mods = {}; + String? _key; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + bool get _isMac => + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS; + + /// True when the captured combo includes the required Ctrl+Alt+Shift + /// (Cmd+Option+Shift on macOS) prefix and a non-modifier key. + bool get _hasRequiredPrefix => + _mods.contains('primary') && + _mods.contains('alt') && + _mods.contains('shift'); + + /// Return the actionId that this combo currently conflicts with, or null. + /// The action being edited is not a conflict with itself. + String? get _conflictActionId { + if (_key == null || !_hasRequiredPrefix) return null; + for (final b in widget.existingBindings) { + final otherAction = b['action'] as String?; + if (otherAction == null || otherAction == widget.actionId) continue; + final otherKey = b['key'] as String?; + final otherMods = + ((b['mods'] as List?) ?? const []).cast().toSet(); + if (otherKey == _key && + otherMods.length == _mods.length && + otherMods.containsAll(_mods)) { + return otherAction; + } + } + return null; + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + return KeyEventResult.handled; + } + if (event is! KeyDownEvent) return KeyEventResult.handled; + + // Ignore modifier-only KeyDowns: don't lock in a partial combo. + final logical = event.logicalKey; + final keyName = _logicalToKeyName(logical); + + final mods = {}; + if (HardwareKeyboard.instance.isAltPressed) mods.add('alt'); + if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift'); + final primary = _isMac + ? HardwareKeyboard.instance.isMetaPressed + : HardwareKeyboard.instance.isControlPressed; + if (primary) mods.add('primary'); + + setState(() { + _mods = mods; + // Only lock in the key when it's a non-modifier we recognize. + // Modifier-only KeyDowns (Shift, Ctrl, etc.) leave the captured key + // untouched, so the user can adjust modifiers after the fact. + if (keyName != null) { + _key = keyName; + } + }); + return KeyEventResult.handled; + } + + void _onSave() { + if (_key == null || !_hasRequiredPrefix) return; + // Sort mods to match the canonical order used by Rust default_bindings: + // primary, alt, shift. + final ordered = [ + if (_mods.contains('primary')) 'primary', + if (_mods.contains('alt')) 'alt', + if (_mods.contains('shift')) 'shift', + ]; + final binding = { + 'action': widget.actionId, + 'mods': ordered, + 'key': _key!, + }; + Navigator.of(context).pop(RecordingResult(binding, _conflictActionId)); + } + + String _formatPrefix() { + if (_isMac) return 'Cmd+Option+Shift'; + return 'Ctrl+Alt+Shift'; + } + + String _formatCombo() { + final parts = []; + for (final m in ['primary', 'alt', 'shift']) { + if (!_mods.contains(m)) continue; + switch (m) { + case 'primary': + parts.add(_isMac ? '⌘' : 'Ctrl'); + break; + case 'alt': + parts.add(_isMac ? '⌥' : 'Alt'); + break; + case 'shift': + parts.add(_isMac ? '⇧' : 'Shift'); + break; + } + } + if (_key != null) { + parts.add(_keyDisplay(_key!)); + } + if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip'); + return _isMac ? parts.join('') : parts.join('+'); + } + + String _keyDisplay(String key) { + switch (key) { + case 'delete': + return _isMac ? '⌫' : 'Del'; + case 'enter': + return _isMac ? '⏎' : 'Enter'; + case 'arrow_left': + return '←'; + case 'arrow_right': + return '→'; + case 'arrow_up': + return '↑'; + case 'arrow_down': + return '↓'; + } + if (key.startsWith('digit')) return key.substring(5); + return key.toUpperCase(); + } + + @override + Widget build(BuildContext context) { + final hasKey = _key != null; + final conflictId = _conflictActionId; + final hasConflict = conflictId != null; + final canSave = hasKey && _hasRequiredPrefix; + + Widget statusLine; + if (!hasKey) { + statusLine = Text( + translate('shortcut-recording-press-keys-tip'), + style: TextStyle(color: Theme.of(context).hintColor), + ); + } else if (!_hasRequiredPrefix) { + statusLine = Row( + children: [ + Icon(Icons.close, size: 16, color: Colors.red), + const SizedBox(width: 6), + Flexible( + child: Text( + '${translate('shortcut-must-include-prefix')} ${_formatPrefix()}', + style: const TextStyle(color: Colors.red), + ), + ), + ], + ); + } else if (hasConflict) { + final otherLabel = widget.actionLabelLookup(conflictId); + statusLine = Row( + children: [ + Icon(Icons.warning_amber_outlined, + size: 16, color: Colors.orange.shade700), + const SizedBox(width: 6), + Flexible( + child: Text( + '${translate('shortcut-already-bound-to')} "$otherLabel"', + style: TextStyle(color: Colors.orange.shade700), + ), + ), + ], + ); + } else { + statusLine = Row( + children: [ + const Icon(Icons.check, size: 16, color: Colors.green), + const SizedBox(width: 6), + Text(translate('Valid'), + style: const TextStyle(color: Colors.green)), + ], + ); + } + + final saveLabel = hasConflict ? 'Replace' : 'Save'; + + return AlertDialog( + title: Text( + '${translate('Set Shortcut')}: ${widget.actionLabel}', + ), + content: Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKeyEvent, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 380), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(translate('shortcut-recording-instruction')), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 18, horizontal: 12), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatCombo(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: hasKey + ? Theme.of(context).textTheme.titleLarge?.color + : Theme.of(context).hintColor, + ), + ), + ), + const SizedBox(height: 12), + statusLine, + ], + ), + ), + ), + actions: [ + dialogButton('Cancel', + onPressed: () => Navigator.of(context).pop(), + isOutline: true), + dialogButton(saveLabel, onPressed: canSave ? _onSave : null), + ], + ); + } + + /// Mirror of `event_to_key_name` in `src/keyboard/shortcuts.rs` and + /// `logicalToKeyName` in `flutter/web/js/src/shortcut_matcher.ts` — keep + /// the three in lockstep. Returns null for modifier-only or unsupported keys. + static String? _logicalToKeyName(LogicalKeyboardKey k) { + if (k == LogicalKeyboardKey.delete) return 'delete'; + if (k == LogicalKeyboardKey.enter || + k == LogicalKeyboardKey.numpadEnter) return 'enter'; + if (k == LogicalKeyboardKey.arrowLeft) return 'arrow_left'; + if (k == LogicalKeyboardKey.arrowRight) return 'arrow_right'; + if (k == LogicalKeyboardKey.arrowUp) return 'arrow_up'; + if (k == LogicalKeyboardKey.arrowDown) return 'arrow_down'; + + final letters = { + LogicalKeyboardKey.keyA: 'a', LogicalKeyboardKey.keyB: 'b', + LogicalKeyboardKey.keyC: 'c', LogicalKeyboardKey.keyD: 'd', + LogicalKeyboardKey.keyE: 'e', LogicalKeyboardKey.keyF: 'f', + LogicalKeyboardKey.keyG: 'g', LogicalKeyboardKey.keyH: 'h', + LogicalKeyboardKey.keyI: 'i', LogicalKeyboardKey.keyJ: 'j', + LogicalKeyboardKey.keyK: 'k', LogicalKeyboardKey.keyL: 'l', + LogicalKeyboardKey.keyM: 'm', LogicalKeyboardKey.keyN: 'n', + LogicalKeyboardKey.keyO: 'o', LogicalKeyboardKey.keyP: 'p', + LogicalKeyboardKey.keyQ: 'q', LogicalKeyboardKey.keyR: 'r', + LogicalKeyboardKey.keyS: 's', LogicalKeyboardKey.keyT: 't', + LogicalKeyboardKey.keyU: 'u', LogicalKeyboardKey.keyV: 'v', + LogicalKeyboardKey.keyW: 'w', LogicalKeyboardKey.keyX: 'x', + LogicalKeyboardKey.keyY: 'y', LogicalKeyboardKey.keyZ: 'z', + }; + if (letters.containsKey(k)) return letters[k]; + + final digits = { + LogicalKeyboardKey.digit1: 'digit1', + LogicalKeyboardKey.digit2: 'digit2', + LogicalKeyboardKey.digit3: 'digit3', + LogicalKeyboardKey.digit4: 'digit4', + LogicalKeyboardKey.digit5: 'digit5', + LogicalKeyboardKey.digit6: 'digit6', + LogicalKeyboardKey.digit7: 'digit7', + LogicalKeyboardKey.digit8: 'digit8', + LogicalKeyboardKey.digit9: 'digit9', + }; + if (digits.containsKey(k)) return digits[k]; + + return null; + } +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 1a6160324..b3c66a383 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -21,11 +21,13 @@ class TTextMenu { 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) { @@ -229,7 +231,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 @@ -250,7 +253,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 +272,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 +285,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 +315,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ) ], ), - onPressed: () => ffi.recordingModel.toggle())); + onPressed: () => ffi.recordingModel.toggle(), + actionId: kShortcutActionToggleRecording)); } // to-do: @@ -342,6 +350,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { }); } }, + actionId: kShortcutActionScreenshot, )); } } @@ -352,6 +361,13 @@ 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 (final menu in v) { + if (menu.actionId != null && menu.onPressed != null) { + ffi.shortcutModel.register(menu.actionId!, menu.onPressed!); + } + } return v; } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 51c08cf33..e10c07729 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -686,3 +686,24 @@ extension WindowsTargetExt on int { } const kCheckSoftwareUpdateFinish = 'check_software_update_finish'; + +// Keyboard shortcut Action IDs - must match src/keyboard/shortcuts.rs::action_id. +const kShortcutActionSendCtrlAltDel = 'send_ctrl_alt_del'; +const kShortcutActionToggleFullscreen = 'toggle_fullscreen'; +const kShortcutActionSwitchDisplayNext = 'switch_display_next'; +const kShortcutActionSwitchDisplayPrev = 'switch_display_prev'; +const kShortcutActionScreenshot = 'screenshot'; +const kShortcutActionInsertLock = 'insert_lock'; +const kShortcutActionRefresh = 'refresh'; +const kShortcutActionToggleAudio = 'toggle_audio'; +const kShortcutActionToggleBlockInput = 'toggle_block_input'; +const kShortcutActionToggleRecording = 'toggle_recording'; +const kShortcutActionTogglePrivacyMode = 'toggle_privacy_mode'; +const kShortcutActionViewMode1to1 = 'view_mode_1_to_1'; +const kShortcutActionViewModeShrink = 'view_mode_shrink'; +const kShortcutActionViewModeStretch = 'view_mode_stretch'; +const kShortcutActionSwitchSides = 'switch_sides'; +String kShortcutActionSwitchTab(int n) => 'switch_tab_$n'; + +const kShortcutLocalConfigKey = 'keyboard-shortcuts'; +const kShortcutEventName = 'shortcut_triggered'; diff --git a/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart b/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart new file mode 100644 index 000000000..03859ada0 --- /dev/null +++ b/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart @@ -0,0 +1,58 @@ +// flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart +// +// Desktop shell for the Keyboard Shortcuts configuration page. Users land +// here from the General settings tab. The page exposes: +// * A top-level enable/disable toggle (mirrors the General-tab toggle — +// same JSON key, same semantics). +// * A grouped, scrollable list of actions, each with a current binding and +// edit / clear icons. +// * An AppBar "Reset to defaults" action with a confirmation dialog. +// +// All edits write back to LocalConfig under [kShortcutLocalConfigKey] in the +// canonical {enabled, bindings:[{action,mods,key}]} shape that the Rust and +// Web matchers consume. +// +// The body — group definitions, JSON I/O, conflict-replace flow, +// recording-dialog round-trip — lives in +// `common/widgets/keyboard_shortcuts/page_body.dart` and is shared with the +// mobile shell at `mobile/pages/mobile_keyboard_shortcuts_page.dart`. + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../common.dart'; +import '../../common/widgets/keyboard_shortcuts/page_body.dart'; + +class DesktopKeyboardShortcutsPage extends StatefulWidget { + const DesktopKeyboardShortcutsPage({Key? key}) : super(key: key); + + @override + State createState() => + _DesktopKeyboardShortcutsPageState(); +} + +class _DesktopKeyboardShortcutsPageState + extends State { + final GlobalKey _bodyKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(translate('Keyboard Shortcuts')), + actions: [ + TextButton.icon( + onPressed: () => + _bodyKey.currentState?.resetToDefaultsWithConfirm(), + icon: const Icon(Icons.restore), + label: Text(translate('Reset to defaults')), + ).marginOnly(right: 12), + ], + ), + body: KeyboardShortcutsPageBody( + key: _bodyKey, + compact: true, + ), + ); + } +} diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index d118b6793..f1e9bfae4 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,57 @@ 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`) is the single + // source of truth — it embeds an `enabled` boolean alongside the bindings + // list. We mutate the JSON in place via _OptionCheckBox's optGetter / + // optSetter hooks rather than introducing a parallel boolean key, so the + // Rust matcher and the Web matcher both read the same flag without drift. + return _Card(title: 'Keyboard Shortcuts', children: [ + _OptionCheckBox( + context, + 'Enable keyboard shortcuts in remote session', + kShortcutLocalConfigKey, + isServer: false, + optGetter: ShortcutModel.isEnabled, + optSetter: (k, v) async { + final raw = bind.mainGetLocalOption(key: k); + Map parsed = {}; + if (raw.isNotEmpty) { + try { + parsed = jsonDecode(raw) as Map; + } catch (_) { + parsed = {}; + } + } + parsed['enabled'] = v; + parsed['bindings'] ??= []; + // Seed defaults the first time the user enables shortcuts so the + // common combos (Ctrl+Alt+Shift+Enter for fullscreen, etc.) work + // out of the box. Mirrors the same logic on the dedicated config + // page. + final list = (parsed['bindings'] as List?) ?? const []; + if (v && list.isEmpty) { + parsed['bindings'] = + jsonDecode(bind.mainGetDefaultKeyboardShortcuts()); + } + await bind.mainSetLocalOption(key: k, value: jsonEncode(parsed)); + // Refresh the matcher cache so the new flag / bindings take effect + // immediately. On native this hits the Rust matcher; on Web the + // bridge forwards to the JS-side matcher in flutter/web/js/. + bind.mainReloadKeyboardShortcuts(); + }, + ), + _ShortcutsConfigureRow(), + ]); + } + Widget theme() { final current = MyTheme.getThemeModePreference().toShortString(); onChanged(String value) async { @@ -2946,6 +2994,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..8df5eb4b5 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,19 @@ 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); + } }); _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 ec05c987f..5488a767c 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/mobile_keyboard_shortcuts_page.dart b/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart new file mode 100644 index 000000000..67a433c39 --- /dev/null +++ b/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart @@ -0,0 +1,95 @@ +// flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart +// +// Mobile shell for the Keyboard Shortcuts configuration page. Mirrors +// `desktop/pages/desktop_keyboard_shortcuts_page.dart` but with a touch- +// friendly layout (ListTile rows instead of dense rows) and a hint banner +// that explains the recording flow only works with a physical keyboard. +// +// All actual logic — group definitions, JSON I/O, conflict-replace flow, +// recording-dialog round-trip, "Reset to defaults" — lives in the shared +// `common/widgets/keyboard_shortcuts/page_body.dart`. This file only +// supplies the AppBar, the AppBar action, and the platform hint banner. +// +// Mobile keyboard detection limitation: Flutter has no reliable +// "is a physical keyboard attached?" API on iOS or Android. Soft keyboards +// don't generate the `KeyDownEvent`s the recording dialog listens for, so +// in practice the dialog only does anything useful when the user actually +// has a hardware keyboard plugged in (USB / Bluetooth / Smart Connector). +// For V1 we don't try to detect attachment — we just surface the +// requirement as an in-page hint instead of disabling the Edit button. + +import 'package:flutter/material.dart'; + +import '../../common.dart'; +import '../../common/widgets/keyboard_shortcuts/page_body.dart'; + +class MobileKeyboardShortcutsPage extends StatefulWidget { + const MobileKeyboardShortcutsPage({Key? key}) : super(key: key); + + @override + State createState() => + _MobileKeyboardShortcutsPageState(); +} + +class _MobileKeyboardShortcutsPageState + extends State { + final GlobalKey _bodyKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text(translate('Keyboard Shortcuts')), + actions: [ + IconButton( + tooltip: translate('Reset to defaults'), + onPressed: () => + _bodyKey.currentState?.resetToDefaultsWithConfirm(), + icon: const Icon(Icons.restore), + ), + ], + ), + body: KeyboardShortcutsPageBody( + key: _bodyKey, + compact: false, + editButtonHint: translate('shortcut-mobile-physical-keyboard-tip'), + headerBanner: _PhysicalKeyboardHintBanner(theme: theme), + ), + ); + } +} + +/// A muted info banner shown above the master toggle on mobile. We can't +/// reliably detect whether a physical keyboard is attached, so instead of +/// disabling the Edit button we surface the requirement up front. +class _PhysicalKeyboardHintBanner extends StatelessWidget { + final ThemeData theme; + const _PhysicalKeyboardHintBanner({required this.theme}); + + @override + Widget build(BuildContext context) { + final color = theme.colorScheme.primary.withOpacity(0.08); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, + size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + translate('shortcut-mobile-physical-keyboard-tip'), + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 9064c122b..7ccd41f08 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..17e69533c 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -699,6 +699,7 @@ class InputModel { } } +<<<<<<< HEAD // 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() { @@ -826,6 +827,7 @@ class InputModel { return KeyEventResult.ignored; } } + if (isWindows || isLinux) { // Ignore meta keys. Because flutter window will loose focus if meta key is pressed. if (e.physicalKey == PhysicalKeyboardKey.metaLeft || 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/models/shortcut_model.dart b/flutter/lib/models/shortcut_model.dart new file mode 100644 index 000000000..b14919d14 --- /dev/null +++ b/flutter/lib/models/shortcut_model.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../common.dart'; +import '../consts.dart'; +import '../desktop/widgets/tabbar_widget.dart' show DesktopTabController; +import '../models/model.dart'; +import '../models/platform_model.dart'; +import '../models/state_model.dart'; + +/// Per-session shortcut dispatcher. Attached to FFI when a session is created. +/// +/// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered` +/// session events containing the matched `action` id. The session event +/// listener in [FfiModel.startEventListener] forwards those to this model +/// via [onTriggered], which runs whatever callback the toolbar / menu +/// builders previously registered for that action id. +class ShortcutModel { + final WeakReference parent; + final Map _callbacks = {}; + + ShortcutModel(this.parent); + + /// Called by toolbar / menu builders to register what to do when the + /// matched shortcut fires. + void register(String actionId, VoidCallback callback) { + _callbacks[actionId] = callback; + } + + void unregister(String actionId) { + _callbacks.remove(actionId); + } + + /// Called by the session event listener when a `shortcut_triggered` event + /// arrives for this session. + void onTriggered(String actionId) { + final cb = _callbacks[actionId]; + if (cb != null) { + cb(); + } else { + debugPrint('shortcut_triggered: no handler for $actionId'); + } + } + + /// Read the bindings JSON from LocalConfig. + static List> readBindings() { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return []; + try { + final parsed = jsonDecode(raw) as Map; + final list = (parsed['bindings'] as List?) ?? []; + return list.cast>(); + } catch (_) { + return []; + } + } + + static bool isEnabled() { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return false; + try { + final parsed = jsonDecode(raw) as Map; + return parsed['enabled'] == true; + } catch (_) { + return false; + } + } +} + +/// Register the default-bound shortcut actions that aren't already wired by +/// `toolbarControls(...)` (which handles things like Ctrl+Alt+Shift+Del and the +/// screenshot action). Called once per session from the desktop / mobile +/// remote page, after the toolbar registrations have run. +/// +/// [tabController] is the desktop window's tab controller; `null` on mobile / +/// web (where tab-switch shortcuts don't apply). +/// +/// Each callback below is a no-op when the underlying state required to +/// service the action isn't available (e.g. only one display, only one tab). +void registerSessionShortcutActions( + FFI ffi, { + DesktopTabController? tabController, +}) { + final sessionId = ffi.sessionId; + + // Toggle Fullscreen — desktop & web-desktop only. `stateGlobal.setFullscreen` + // handles native window vs. browser fullscreen; on mobile fullscreen is the + // permanent default, so we leave the action unregistered (becomes a logged + // no-op if a mobile user binds it). + if (isDesktop || isWebDesktop) { + ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () { + stateGlobal.setFullscreen(!stateGlobal.fullscreen.value); + }); + } + + // Switch Display Next / Prev — requires the peer to have at least 2 + // displays. No-op when only one display is available or when the user has + // selected the "All displays" pseudo-display. + void switchDisplayBy(int delta) { + final pi = ffi.ffiModel.pi; + final count = pi.displays.length; + if (count <= 1) return; + final current = pi.currentDisplay; + if (current == kAllDisplayValue) return; + final next = ((current + delta) % count + count) % count; + bind.sessionSwitchDisplay( + isDesktop: isDesktop, + sessionId: sessionId, + value: Int32List.fromList([next]), + ); + if (pi.isSupportMultiUiSession) { + // On multi-ui-session peers no switch-display message is sent back, so + // update the local state directly (mirrors `model.dart` handling). + ffi.ffiModel.switchToNewDisplay(next, sessionId, ffi.id); + } + } + + ffi.shortcutModel.register(kShortcutActionSwitchDisplayNext, () { + switchDisplayBy(1); + }); + ffi.shortcutModel.register(kShortcutActionSwitchDisplayPrev, () { + switchDisplayBy(-1); + }); + + // Switch Tab 1..9 — desktop only. The remote-screen tabs live in the + // window-scoped DesktopTabController, not on the FFI itself, so we need + // the controller from the page that owns this session. No-op on mobile / + // web (no controller passed) and when the requested tab index is out of + // range. + if (tabController != null) { + for (var n = 1; n <= 9; n++) { + final idx = n - 1; + ffi.shortcutModel.register(kShortcutActionSwitchTab(n), () { + if (tabController.state.value.tabs.length > idx) { + tabController.jumpTo(idx); + } + }); + } + } +} diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index a3d93f88e..fd97e5d8a 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,30 @@ 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', []); + } + + // Mirror of `default_bindings()` in `src/keyboard/shortcuts.rs`. Keep these + // two lists in sync — if you add or change a default binding on the Rust + // side, update the literal below to match. + String mainGetDefaultKeyboardShortcuts({dynamic hint}) { + const prefix = ['primary', 'alt', 'shift']; + final list = >[ + {'action': 'send_ctrl_alt_del', 'mods': prefix, 'key': 'delete'}, + {'action': 'toggle_fullscreen', 'mods': prefix, 'key': 'enter'}, + {'action': 'switch_display_next', 'mods': prefix, 'key': 'arrow_right'}, + {'action': 'switch_display_prev', 'mods': prefix, 'key': 'arrow_left'}, + {'action': 'screenshot', 'mods': prefix, 'key': 'p'}, + for (var n = 1; n <= 9; n++) + {'action': 'switch_tab_$n', 'mods': prefix, 'key': 'digit$n'}, + ]; + return jsonEncode(list); + } + String mainGetInputSource({dynamic hint}) { final inputSource = js.context.callMethod('getByName', ['option:local', 'input-source']); @@ -1176,6 +1201,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(); } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 1ee13f4df..0e675b8a6 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -575,6 +575,7 @@ pub fn session_handle_flutter_key_event( if let Some(session) = sessions::get_session_by_session_id(&session_id) { let keyboard_mode = session.get_keyboard_mode(); session.handle_flutter_key_event( + session_id, &keyboard_mode, &character, usb_hid, @@ -595,6 +596,7 @@ pub fn session_handle_flutter_raw_key_event( if let Some(session) = sessions::get_session_by_session_id(&session_id) { let keyboard_mode = session.get_keyboard_mode(); session.handle_flutter_raw_key_event( + session_id, &keyboard_mode, &name, platform_code, @@ -1728,6 +1730,7 @@ pub fn cm_get_clients_length() -> usize { pub fn main_init(app_dir: String, custom_client_config: String) { initialize(&app_dir, &custom_client_config); + crate::keyboard::shortcuts::reload_from_config(); } pub fn main_device_id(id: String) { @@ -2247,6 +2250,17 @@ pub fn main_init_input_source() -> SyncReturn<()> { SyncReturn(()) } +pub fn main_reload_keyboard_shortcuts() -> SyncReturn<()> { + crate::keyboard::shortcuts::reload_from_config(); + SyncReturn(()) +} + +pub fn main_get_default_keyboard_shortcuts() -> SyncReturn { + let bindings = crate::keyboard::shortcuts::default_bindings(); + let json = serde_json::to_string(&bindings).unwrap_or_default(); + SyncReturn(json) +} + pub fn main_is_installed_lower_version() -> SyncReturn { SyncReturn(is_installed_lower_version()) } diff --git a/src/keyboard.rs b/src/keyboard.rs index b9cf4da2d..0e3cff85e 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState}; #[cfg(not(any(target_os = "android", target_os = "ios")))] use hbb_common::log; use hbb_common::message_proto::*; +use hbb_common::SessionID; #[cfg(any(target_os = "windows", target_os = "macos"))] use rdev::KeyCode; use rdev::{Event, EventType, Key}; @@ -79,6 +80,8 @@ lazy_static::lazy_static! { }; } +pub mod shortcuts; + pub mod client { use super::*; @@ -319,6 +322,33 @@ pub mod client { } pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option) { + // Shortcut intercept — must come before any wire encoding. + // Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None + // for KeyRelease and other non-press events), so flushed releases from + // release_remote_keys pass straight through to the encode/forward path. + if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) { + #[cfg(feature = "flutter")] + { + // The rdev grab loop is genuinely process-wide: it does not know which + // Flutter SessionID the keystroke was meant for, so we route to the + // globally-current session via flutter::get_cur_session_id() (maintained + // by session_enter_or_leave). This is the only behavior available on the + // rdev path; the Flutter path threads the explicit per-call SessionID + // through process_event_with_session instead. + let session_id = crate::flutter::get_cur_session_id(); + crate::flutter::push_session_event( + &session_id, + "shortcut_triggered", + vec![("action", &action_id)], + ); + } + #[cfg(not(feature = "flutter"))] + { + let _ = action_id; + } + return; + } + let keyboard_mode = get_keyboard_mode_enum(keyboard_mode); if is_long_press(&event) { return; @@ -334,7 +364,33 @@ pub mod client { event: &Event, lock_modes: Option, session: &Session, + session_id: SessionID, ) { + // Shortcut intercept — must come before any wire encoding. + // Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None + // for KeyRelease and other non-press events), so flushed releases from + // release_remote_keys pass straight through to the encode/forward path. + if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) { + #[cfg(feature = "flutter")] + { + // The Flutter path threads the explicit SessionID from the FFI entry + // (session_handle_flutter_*key_event) through this call, so the dispatch + // targets the exact tab the keystroke originated from — no dependency on + // the global focus tracker and no multi-window race. + crate::flutter::push_session_event( + &session_id, + "shortcut_triggered", + vec![("action", &action_id)], + ); + } + #[cfg(not(feature = "flutter"))] + { + let _ = action_id; + let _ = session_id; + } + return; + } + let keyboard_mode = get_keyboard_mode_enum(keyboard_mode); if is_long_press(&event) { return; diff --git a/src/keyboard/shortcuts.rs b/src/keyboard/shortcuts.rs new file mode 100644 index 000000000..be141576c --- /dev/null +++ b/src/keyboard/shortcuts.rs @@ -0,0 +1,370 @@ +//! Keyboard shortcuts for triggering session actions locally. + +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; + +const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts"; + +lazy_static::lazy_static! { + static ref CACHE: RwLock> = RwLock::new(Arc::new(Bindings::default())); +} + +/// Registry of all valid action ids that may appear in `Binding.action`. +/// Source-of-truth lives on the Flutter side (`flutter/lib/consts.dart`, +/// `kShortcutAction*`); these mirror that vocabulary so Rust code can reach +/// for them without re-stringifying. +#[allow(dead_code)] +pub mod action_id { + pub const SEND_CTRL_ALT_DEL: &str = "send_ctrl_alt_del"; + pub const TOGGLE_FULLSCREEN: &str = "toggle_fullscreen"; + pub const SWITCH_DISPLAY_NEXT: &str = "switch_display_next"; + pub const SWITCH_DISPLAY_PREV: &str = "switch_display_prev"; + pub const SCREENSHOT: &str = "screenshot"; + pub const INSERT_LOCK: &str = "insert_lock"; + pub const REFRESH: &str = "refresh"; + pub const TOGGLE_AUDIO: &str = "toggle_audio"; + pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input"; + pub const TOGGLE_RECORDING: &str = "toggle_recording"; + pub const TOGGLE_PRIVACY_MODE: &str = "toggle_privacy_mode"; + pub const VIEW_MODE_1_TO_1: &str = "view_mode_1_to_1"; + pub const VIEW_MODE_SHRINK: &str = "view_mode_shrink"; + pub const VIEW_MODE_STRETCH: &str = "view_mode_stretch"; + pub const SWITCH_SIDES: &str = "switch_sides"; + // switch_tab_1 .. switch_tab_9 are generated below. +} + +pub fn switch_tab_action_id(n: u8) -> Option<&'static str> { + match n { + 1 => Some("switch_tab_1"), + 2 => Some("switch_tab_2"), + 3 => Some("switch_tab_3"), + 4 => Some("switch_tab_4"), + 5 => Some("switch_tab_5"), + 6 => Some("switch_tab_6"), + 7 => Some("switch_tab_7"), + 8 => Some("switch_tab_8"), + 9 => Some("switch_tab_9"), + _ => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Modifier { + Primary, + Alt, + Shift, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Binding { + pub action: String, + pub mods: Vec, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Bindings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub bindings: Vec, +} + +pub fn default_bindings() -> Vec { + let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift]; + let mut v = vec![ + Binding { action: action_id::SEND_CTRL_ALT_DEL.into(), mods: prefix(), key: "delete".into() }, + Binding { action: action_id::TOGGLE_FULLSCREEN.into(), mods: prefix(), key: "enter".into() }, + Binding { action: action_id::SWITCH_DISPLAY_NEXT.into(), mods: prefix(), key: "arrow_right".into() }, + Binding { action: action_id::SWITCH_DISPLAY_PREV.into(), mods: prefix(), key: "arrow_left".into() }, + Binding { action: action_id::SCREENSHOT.into(), mods: prefix(), key: "p".into() }, + ]; + for n in 1..=9u8 { + if let Some(action) = switch_tab_action_id(n) { + v.push(Binding { + action: action.into(), + mods: prefix(), + key: format!("digit{n}"), + }); + } + } + v +} + +/// Match a normalized (key, modifiers) pair against the given bindings. +/// Returns the matched action ID, or None. +pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> { + if !b.enabled { + return None; + } + for binding in &b.bindings { + if binding.key == key && mods_equal(&binding.mods, mods) { + return Some(binding.action.as_str()); + } + } + None +} + +pub fn normalize_modifiers(alt: bool, ctrl: bool, shift: bool, command: bool) -> Vec { + let mut v = Vec::new(); + let primary = if cfg!(target_os = "macos") { command } else { ctrl }; + if primary { v.push(Modifier::Primary); } + if alt { v.push(Modifier::Alt); } + if shift { v.push(Modifier::Shift); } + v +} + +/// Map an rdev::Event to a string key name, matching the storage schema. +/// Returns None for events we don't intercept (modifier-only presses, releases, etc.). +pub fn event_to_key_name(event: &rdev::Event) -> Option { + use rdev::{EventType, Key}; + let key = match event.event_type { + EventType::KeyPress(k) => k, + _ => return None, + }; + Some(match key { + Key::Delete => "delete".into(), + Key::Return => "enter".into(), + Key::LeftArrow => "arrow_left".into(), + Key::RightArrow => "arrow_right".into(), + Key::UpArrow => "arrow_up".into(), + Key::DownArrow => "arrow_down".into(), + Key::KeyA => "a".into(), + Key::KeyB => "b".into(), + Key::KeyC => "c".into(), + Key::KeyD => "d".into(), + Key::KeyE => "e".into(), + Key::KeyF => "f".into(), + Key::KeyG => "g".into(), + Key::KeyH => "h".into(), + Key::KeyI => "i".into(), + Key::KeyJ => "j".into(), + Key::KeyK => "k".into(), + Key::KeyL => "l".into(), + Key::KeyM => "m".into(), + Key::KeyN => "n".into(), + Key::KeyO => "o".into(), + Key::KeyP => "p".into(), + Key::KeyQ => "q".into(), + Key::KeyR => "r".into(), + Key::KeyS => "s".into(), + Key::KeyT => "t".into(), + Key::KeyU => "u".into(), + Key::KeyV => "v".into(), + Key::KeyW => "w".into(), + Key::KeyX => "x".into(), + Key::KeyY => "y".into(), + Key::KeyZ => "z".into(), + Key::Num1 => "digit1".into(), + Key::Num2 => "digit2".into(), + Key::Num3 => "digit3".into(), + Key::Num4 => "digit4".into(), + Key::Num5 => "digit5".into(), + Key::Num6 => "digit6".into(), + Key::Num7 => "digit7".into(), + Key::Num8 => "digit8".into(), + Key::Num9 => "digit9".into(), + _ => return None, + }) +} + +/// Read keyboard-shortcut bindings from `LocalConfig` and refresh the cache. +/// +/// Empty or invalid JSON falls back to `Bindings::default()` (disabled, no +/// bindings). Call this once at startup and again whenever the config is +/// written. +pub fn reload_from_config() { + let raw = hbb_common::config::LocalConfig::get_option(LOCAL_CONFIG_KEY); + let parsed = if raw.is_empty() { + Bindings::default() + } else { + serde_json::from_str(&raw).unwrap_or_default() + }; + if let Ok(mut w) = CACHE.write() { + *w = Arc::new(parsed); + } +} + +/// Snapshot of the currently cached bindings. Cheap (one atomic increment) — +/// safe to call on every keystroke. +pub fn current() -> Arc { + CACHE + .read() + .map(|b| Arc::clone(&b)) + .unwrap_or_else(|_| Arc::new(Bindings::default())) +} + +/// Match an `rdev::Event` against the cached bindings. Returns the matched +/// action id, or `None` if no binding fires. The Flutter side ignores unknown +/// action ids (logged as "no handler"), so no whitelist check is needed here. +pub fn match_event(event: &rdev::Event) -> Option { + let bindings = current(); + if !bindings.enabled { + return None; + } + let key_name = event_to_key_name(event)?; + let (alt, ctrl, shift, command) = + crate::keyboard::client::get_modifiers_state(false, false, false, false); + let mods = normalize_modifiers(alt, ctrl, shift, command); + match_normalized(&key_name, &mods, &bindings).map(str::to_owned) +} + +fn mods_bits(m: &[Modifier]) -> u8 { + let mut bits = 0u8; + for x in m { + bits |= match x { + Modifier::Primary => 1, + Modifier::Alt => 2, + Modifier::Shift => 4, + }; + } + bits +} + +fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool { + mods_bits(a) == mods_bits(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindings_round_trip_json() { + let json = r#"{ + "enabled": true, + "bindings": [ + {"action": "send_ctrl_alt_del", "mods": ["primary","alt","shift"], "key": "delete"}, + {"action": "toggle_fullscreen", "mods": ["primary","alt","shift"], "key": "enter"} + ] + }"#; + let parsed: Bindings = serde_json::from_str(json).expect("parse"); + assert!(parsed.enabled); + assert_eq!(parsed.bindings.len(), 2); + assert_eq!(parsed.bindings[0].action, "send_ctrl_alt_del"); + assert_eq!(parsed.bindings[0].key, "delete"); + + let serialized = serde_json::to_string(&parsed).expect("serialize"); + let reparsed: Bindings = serde_json::from_str(&serialized).expect("reparse"); + assert_eq!(parsed, reparsed); + } + + #[test] + fn defaults_match_design_doc() { + let defaults = default_bindings(); + let actions: Vec<&str> = defaults.iter().map(|b| b.action.as_str()).collect(); + assert!(actions.contains(&action_id::SEND_CTRL_ALT_DEL)); + assert!(actions.contains(&action_id::TOGGLE_FULLSCREEN)); + assert!(actions.contains(&action_id::SWITCH_DISPLAY_NEXT)); + assert!(actions.contains(&action_id::SWITCH_DISPLAY_PREV)); + assert!(actions.contains(&action_id::SCREENSHOT)); + assert!(actions.contains(&"switch_tab_1")); + assert!(actions.contains(&"switch_tab_9")); + // every default binding includes the three-modifier prefix + for b in &defaults { + assert!(b.mods.contains(&Modifier::Primary)); + assert!(b.mods.contains(&Modifier::Alt)); + assert!(b.mods.contains(&Modifier::Shift)); + } + } + + fn match_for_test<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> { + match_normalized(key, mods, b) + } + + #[test] + fn match_returns_none_when_disabled() { + let bindings = Bindings { enabled: false, bindings: default_bindings() }; + let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings); + assert_eq!(result, None); + } + + #[test] + fn match_screenshot_when_enabled() { + let bindings = Bindings { enabled: true, bindings: default_bindings() }; + let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings); + assert_eq!(result, Some(action_id::SCREENSHOT)); + } + + #[test] + fn match_returns_none_when_modifiers_partial() { + let bindings = Bindings { enabled: true, bindings: default_bindings() }; + // missing Shift + let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt], &bindings); + assert_eq!(result, None); + } + + #[test] + fn match_does_not_fire_on_extra_unbound_keys() { + let bindings = Bindings { enabled: true, bindings: default_bindings() }; + let result = match_for_test("z", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings); + assert_eq!(result, None); + } + + #[test] + fn match_handles_duplicate_modifiers_in_input() { + // A user-edited config could contain duplicate modifiers; the matcher must + // treat the modifier list as a set, not a multiset. + let bindings = Bindings { + enabled: true, + bindings: vec![Binding { + action: "x".into(), + mods: vec![Modifier::Primary, Modifier::Alt], + key: "a".into(), + }], + }; + // Caller passes Primary twice — must not match a binding with Primary+Alt. + assert_eq!( + match_normalized("a", &[Modifier::Primary, Modifier::Primary], &bindings), + None, + ); + // Caller passes Primary+Alt with one duplicate — should still match. + assert_eq!( + match_normalized("a", &[Modifier::Primary, Modifier::Alt, Modifier::Alt], &bindings), + Some("x"), + ); + } + + #[test] + fn modifier_normalization_primary_resolves_per_os() { + // On Win/Linux: pressing Ctrl satisfies Primary + let mods = normalize_modifiers(/*alt=*/true, /*ctrl=*/true, /*shift=*/true, /*command=*/false); + if cfg!(target_os = "macos") { + // On macOS Ctrl is NOT primary + assert!(!mods.contains(&Modifier::Primary)); + } else { + assert!(mods.contains(&Modifier::Primary)); + } + assert!(mods.contains(&Modifier::Alt)); + assert!(mods.contains(&Modifier::Shift)); + } + + #[test] + fn modifier_normalization_command_is_primary_on_mac() { + let mods = normalize_modifiers(true, false, true, /*command=*/true); + if cfg!(target_os = "macos") { + assert!(mods.contains(&Modifier::Primary)); + } else { + // On Win/Linux Command/Meta is NOT primary + assert!(!mods.contains(&Modifier::Primary)); + } + } + + #[test] + fn reload_handles_missing_and_invalid_json() { + // empty (no value set) → defaults + hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), String::new()); + reload_from_config(); + let b = current(); + assert!(!b.enabled); + assert!(b.bindings.is_empty()); + + // invalid JSON → defaults (no panic) + hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), "not json".into()); + reload_from_config(); + let b = current(); + assert!(!b.enabled); + } +} diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 75d16ff92..ff088a09b 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -743,5 +743,44 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "显示名称"), ("password-hidden-tip", "永久密码已设置(已隐藏)"), ("preset-password-in-use-tip", "当前使用预设密码"), + ("Keyboard Shortcuts", "键盘快捷键"), + ("Configure shortcuts...", "配置快捷键..."), + ("Enable keyboard shortcuts in remote session", "在远程会话中启用键盘快捷键"), + ("shortcut-page-description", "启用后,列出的组合键将在本地触发会话操作,而不会发送到远程端。所有快捷键必须包含 Ctrl+Alt+Shift(macOS 上为 Cmd+Option+Shift),以避免与正常输入冲突。"), + ("Reset to defaults", "恢复默认设置"), + ("shortcut-reset-confirm-tip", "这将以默认快捷键替换所有当前绑定。是否继续?"), + ("Session Control", "会话控制"), + ("Toggle Fullscreen", "切换全屏"), + ("Switch to next display", "切换到下一个显示器"), + ("Switch to previous display", "切换到上一个显示器"), + ("View Mode 1:1", "原始大小"), + ("View Mode Shrink", "缩小"), + ("View Mode Stretch", "拉伸"), + ("Take Screenshot", "截图"), + ("Toggle Audio", "切换音频"), + ("Toggle Privacy Mode", "切换隐私模式"), + ("Toggle Recording", "切换录制"), + ("Toggle Block User Input", "切换屏蔽用户输入"), + ("Switch Tab 1", "切换到第 1 个标签"), + ("Switch Tab 2", "切换到第 2 个标签"), + ("Switch Tab 3", "切换到第 3 个标签"), + ("Switch Tab 4", "切换到第 4 个标签"), + ("Switch Tab 5", "切换到第 5 个标签"), + ("Switch Tab 6", "切换到第 6 个标签"), + ("Switch Tab 7", "切换到第 7 个标签"), + ("Switch Tab 8", "切换到第 8 个标签"), + ("Switch Tab 9", "切换到第 9 个标签"), + ("Edit", "编辑"), + ("Save", "保存"), + ("Set Shortcut", "设置快捷键"), + ("shortcut-recording-instruction", "请按下您想使用的组合键。"), + ("shortcut-recording-press-keys-tip", "请按下组合键..."), + ("shortcut-must-include-prefix", "必须包含"), + ("shortcut-already-bound-to", "已绑定到"), + ("Replace", "替换"), + ("Valid", "有效"), + ("shortcut-mobile-physical-keyboard-tip", "录制需要使用物理键盘,不支持软键盘。"), + ("On", "开"), + ("Off", "关"), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 73974a2e5..b78e806c3 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -274,5 +274,47 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"), ("password-hidden-tip", "Permanent password is set (hidden)."), ("preset-password-in-use-tip", "Preset password is currently in use."), + ("Keyboard Shortcuts", ""), + ("Configure shortcuts...", ""), + ("Enable keyboard shortcuts in remote session", ""), + ("shortcut-page-description", "When enabled, listed key combinations trigger session actions locally instead of being sent to the remote. All bindings must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS) to avoid conflicts with normal typing."), + ("Reset to defaults", ""), + ("shortcut-reset-confirm-tip", "This will replace all current bindings with the default set. Continue?"), + ("Session Control", ""), + ("Display", ""), + ("Other", ""), + ("Toggle Fullscreen", ""), + ("Switch to next display", ""), + ("Switch to previous display", ""), + ("View Mode 1:1", ""), + ("View Mode Shrink", ""), + ("View Mode Stretch", ""), + ("Take Screenshot", ""), + ("Toggle Audio", ""), + ("Toggle Privacy Mode", ""), + ("Toggle Recording", ""), + ("Toggle Block User Input", ""), + ("Switch Tab 1", ""), + ("Switch Tab 2", ""), + ("Switch Tab 3", ""), + ("Switch Tab 4", ""), + ("Switch Tab 5", ""), + ("Switch Tab 6", ""), + ("Switch Tab 7", ""), + ("Switch Tab 8", ""), + ("Switch Tab 9", ""), + ("Edit", ""), + ("Save", ""), + ("Set Shortcut", ""), + ("shortcut-recording-instruction", "Press the key combination you want to use."), + ("shortcut-recording-press-keys-tip", "Press a key combination..."), + ("shortcut-must-include-prefix", "Must include"), + ("shortcut-already-bound-to", "Already bound to"), + ("Replace", ""), + ("Valid", ""), + ("shortcut-mobile-physical-keyboard-tip", "Recording requires a physical keyboard. Soft keyboards are not supported."), + ("Clear", ""), + ("On", ""), + ("Off", ""), ].iter().cloned().collect(); } diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index c18c17fe2..c96bd39b8 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -23,7 +23,7 @@ use hbb_common::{ sync::mpsc, time::{Duration as TokioDuration, Instant}, }, - whoami, Stream, + whoami, SessionID, Stream, }; use rdev::{Event, EventType::*, KeyCode}; #[cfg(all(feature = "vram", feature = "flutter"))] @@ -913,6 +913,7 @@ impl Session { #[cfg(any(target_os = "ios"))] pub fn handle_flutter_raw_key_event( &self, + _session_id: SessionID, _keyboard_mode: &str, _name: &str, _platform_code: i32, @@ -925,6 +926,7 @@ impl Session { #[cfg(not(any(target_os = "ios")))] pub fn handle_flutter_raw_key_event( &self, + session_id: SessionID, keyboard_mode: &str, name: &str, platform_code: i32, @@ -936,6 +938,7 @@ impl Session { self._handle_key_flutter_simulation(keyboard_mode, platform_code, down_or_up); } else { self._handle_raw_key_non_flutter_simulation( + session_id, keyboard_mode, platform_code, position_code, @@ -948,6 +951,7 @@ impl Session { #[cfg(not(any(target_os = "ios")))] fn _handle_raw_key_non_flutter_simulation( &self, + session_id: SessionID, keyboard_mode: &str, platform_code: i32, position_code: i32, @@ -981,11 +985,18 @@ impl Session { #[cfg(any(target_os = "windows", target_os = "macos"))] extra_data: 0, }; - keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self); + keyboard::client::process_event_with_session( + keyboard_mode, + &event, + Some(lock_modes), + self, + session_id, + ); } pub fn handle_flutter_key_event( &self, + session_id: SessionID, keyboard_mode: &str, character: &str, usb_hid: i32, @@ -996,6 +1007,7 @@ impl Session { self._handle_key_flutter_simulation(keyboard_mode, usb_hid, down_or_up); } else { self._handle_key_non_flutter_simulation( + session_id, keyboard_mode, character, usb_hid, @@ -1031,6 +1043,7 @@ impl Session { fn _handle_key_non_flutter_simulation( &self, + session_id: SessionID, keyboard_mode: &str, character: &str, usb_hid: i32, @@ -1092,7 +1105,13 @@ impl Session { #[cfg(any(target_os = "windows", target_os = "macos"))] extra_data: 0, }; - keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self); + keyboard::client::process_event_with_session( + keyboard_mode, + &event, + Some(lock_modes), + self, + session_id, + ); } // flutter only TODO new input From d4a1430c27e4d07cc4e37f737a256b23eaf52cd5 Mon Sep 17 00:00:00 2001 From: orbisai0security Date: Wed, 29 Apr 2026 10:45:21 +0530 Subject: [PATCH 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] 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 11/36] 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 12/36] 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 13/36] 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(); } From 6c20fc936d04d0290415ca749cdd624b28969380 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 7 May 2026 13:27:13 +0800 Subject: [PATCH 14/36] Terminal utf8 and reconnect (#14895) * fix: handle incomplete UTF-8 sequences in terminal output, rework on https://github.com/rustdesk/rustdesk/pull/14736 * Fix terminal auto-reconnect freeze: reconnect resumes terminal output, while multi-tab reconnect avoids restoring duplicate tabs for terminals that are already open. * fix(terminal): subtract with overflow ``` thread '' panicked at src\server\terminal_service.rs:476:17: attempt to subtract with overflow note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace thread 'tokio-runtime-worker' panicked at src\server\terminal_service.rs:1576:50: called `Result::unwrap()` on an `Err` value: PoisonError { .. } [2026-04-25T07:17:34Z ERROR librustdesk::server::service] Failed to join thread for service ts_9badd3fe-2411-4996-9f40-93c979009edd, Any { .. } ``` Signed-off-by: fufesou * fix ios enter: https://github.com/rustdesk/rustdesk/issues/14907 * fix(terminal): reconnect, error handling 1. Terminal shows "^[[1;1R^[[2;2R^[[>0;0;0c" 2. NaN ``` [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Converting object to an encodable object failed: NaN ... ``` Signed-off-by: fufesou * fix(terminal): dialog, close window Signed-off-by: fufesou * fix(terminal): close terminal window on disconnect dialog Signed-off-by: fufesou * fix(terminal): merge reconnect backlog into replay output Signed-off-by: fufesou * fix(terminal): avoid reconnect stalls and delayed layout writes Signed-off-by: fufesou * fix(terminal): remove invalid test Signed-off-by: fufesou * fix(terminal): schedule frame before flushing buffered output Signed-off-by: fufesou * fix(terminal): windows&macos, charset utf-8 Signed-off-by: fufesou * fix(terminal): reconnect suppress next output Signed-off-by: fufesou * fix: cap terminal reconnect replay output - split reconnect replay backlog into capped chunks - mark terminal data replay chunks for client-side suppression - avoid using open-message text to suppress xterm replies - reuse default terminal padding value - remove misleading Enter-key normalization PR link Signed-off-by: fufesou * fix(terminal): env en_US.UTF-8 Signed-off-by: fufesou * fix(terminal): reconnect, refactor Signed-off-by: fufesou * fix(terminal): flag, retry output Signed-off-by: fufesou * fix(terminal): update hbb_common Signed-off-by: fufesou * fix(terminal): comments Signed-off-by: fufesou * fix(terminal): comments utf-8 chunk accumulator Signed-off-by: fufesou * fix(terminal): update hbb_common Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: fufesou --- flutter/lib/common.dart | 14 +- flutter/lib/desktop/pages/terminal_page.dart | 28 +- .../lib/desktop/pages/terminal_tab_page.dart | 36 +- .../lib/desktop/widgets/tabbar_widget.dart | 1 + flutter/lib/models/terminal_model.dart | 116 +++-- libs/hbb_common | 2 +- src/flutter.rs | 4 + src/server/terminal_helper.rs | 32 +- src/server/terminal_service.rs | 407 ++++++++++++++++-- 9 files changed, 560 insertions(+), 80 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index e579db36a..366a7b6ba 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -716,6 +716,17 @@ closeConnection({String? id}) { stateGlobal.isInMainPage = true; } else { final controller = Get.find(); + if (controller.tabType == DesktopTabType.terminal && + controller.onCloseWindow != null) { + // Terminal windows are scoped to one peer. The optional id passed to + // closeConnection() is that peer id, not a terminal tab key + // (${peerId}_${terminalId}). Closing from terminal dialogs should close + // the peer's whole terminal window, including all terminal tabs. + unawaited(controller.onCloseWindow!().catchError((e, _) { + debugPrint('[closeConnection] Failed to close terminal window: $e'); + })); + return; + } controller.closeBy(id); } } @@ -4179,8 +4190,7 @@ Widget? buildAvatarWidget({ width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - fallback ?? SizedBox.shrink(), + errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(), ), ); } diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index 0070cd73b..d38dc4a8b 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget { final bool? isSharedPassword; final String? connToken; final int terminalId; + /// Tab key for focus management, passed from parent to avoid duplicate construction final String tabKey; final SimpleWrapper?> _lastState = SimpleWrapper(null); @@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget { class _TerminalPageState extends State with AutomaticKeepAliveClientMixin { + static const EdgeInsets _defaultTerminalPadding = + EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); + late FFI _ffi; late TerminalModel _terminalModel; double? _cellHeight; @@ -155,13 +159,27 @@ class _TerminalPageState extends State // extra space left after dividing the available height by the height of a single // terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding. EdgeInsets _calculatePadding(double heightPx) { - if (_cellHeight == null) { - return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); + final cellHeight = _cellHeight; + if (!heightPx.isFinite || + heightPx <= 0 || + cellHeight == null || + !cellHeight.isFinite || + cellHeight <= 0) { + return _defaultTerminalPadding; + } + final rows = (heightPx / cellHeight).floor(); + if (rows <= 0) { + return _defaultTerminalPadding; + } + final extraSpace = heightPx - rows * cellHeight; + if (!extraSpace.isFinite || extraSpace < 0) { + return _defaultTerminalPadding; } - final rows = (heightPx / _cellHeight!).floor(); - final extraSpace = heightPx - rows * _cellHeight!; final topBottom = extraSpace / 2.0; - return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom); + return EdgeInsets.symmetric( + horizontal: _defaultTerminalPadding.horizontal / 2, + vertical: topBottom, + ); } @override diff --git a/flutter/lib/desktop/pages/terminal_tab_page.dart b/flutter/lib/desktop/pages/terminal_tab_page.dart index 28e59fb05..63289e94d 100644 --- a/flutter/lib/desktop/pages/terminal_tab_page.dart +++ b/flutter/lib/desktop/pages/terminal_tab_page.dart @@ -46,6 +46,7 @@ class _TerminalTabPageState extends State { .setTitle(getWindowNameWithId(id)); }; tabController.onRemoved = (_, id) => onRemoveId(id); + tabController.onCloseWindow = _closeWindowFromConnection; final terminalId = params['terminalId'] ?? _nextTerminalId++; tabController.add(_createTerminalTab( peerId: params['id'], @@ -144,6 +145,8 @@ class _TerminalTabPageState extends State { _windowClosing = true; final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList(); // Remove all UI tabs immediately (same instant behavior as the old tabController.clear()) + // Keep the cleanup target lookup below synchronous before its first await: + // it relies on the current frame still retaining each TerminalPage's FFI/model. tabController.clear(); // Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout). // Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls. @@ -368,8 +371,34 @@ class _TerminalTabPageState extends State { final persistentSessions = args['persistent_sessions'] as List? ?? []; final sortedSessions = persistentSessions.whereType().toList()..sort(); + var peerId = args['peer_id'] as String? ?? ''; + if (peerId.isEmpty) { + if (tabController.state.value.tabs.isEmpty || + tabController.state.value.selected >= + tabController.state.value.tabs.length) { + debugPrint('[TerminalTabPage] Skip restore: no selected tab'); + return; + } + final currentTab = tabController.state.value.selectedTabInfo; + final parsed = _parseTabKey(currentTab.key); + if (parsed == null) return; + peerId = parsed.$1; + } + final existingTerminalIds = tabController.state.value.tabs + .map((tab) => _parseTabKey(tab.key)) + .where((parsed) => parsed != null && parsed.$1 == peerId) + .map((parsed) => parsed!.$2) + .toSet(); + if (existingTerminalIds.isEmpty) { + debugPrint( + '[TerminalTabPage] Skip restore: no seed tab for peer $peerId'); + return; + } for (final terminalId in sortedSessions) { - _addNewTerminalForCurrentPeer(terminalId: terminalId); + if (!existingTerminalIds.add(terminalId)) { + continue; + } + _addNewTerminal(peerId, terminalId: terminalId); // A delay is required to ensure the UI has sufficient time to update // before adding the next terminal. Without this delay, `_TerminalPageState::dispose()` // may be called prematurely while the tab widget is still in the tab controller. @@ -546,6 +575,11 @@ class _TerminalTabPageState extends State { } } + Future _closeWindowFromConnection() async { + await _closeAllTabs(); + await WindowController.fromWindowId(windowId()).close(); + } + int windowId() { return widget.params["windowId"]; } diff --git a/flutter/lib/desktop/widgets/tabbar_widget.dart b/flutter/lib/desktop/widgets/tabbar_widget.dart index ac7d80017..ef195b493 100644 --- a/flutter/lib/desktop/widgets/tabbar_widget.dart +++ b/flutter/lib/desktop/widgets/tabbar_widget.dart @@ -99,6 +99,7 @@ class DesktopTabController { /// index, key Function(int, String)? onRemoved; Function(String)? onSelected; + Future Function()? onCloseWindow; DesktopTabController( {required this.tabType, this.onRemoved, this.onSelected}); diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index a74241ccb..8961d2dd8 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -27,25 +27,30 @@ class TerminalModel with ChangeNotifier { // Buffer for output data received before terminal view has valid dimensions. // This prevents NaN errors when writing to terminal before layout is complete. final _pendingOutputChunks = []; + final _pendingOutputSuppressFlags = []; int _pendingOutputSize = 0; static const int _kMaxOutputBufferChars = 8 * 1024; // View ready state: true when terminal has valid dimensions, safe to write bool _terminalViewReady = false; - - bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows; + bool _markViewReadyScheduled = false; + bool _suppressTerminalOutput = false; + bool _suppressNextTerminalDataOutput = false; void Function(int w, int h, int pw, int ph)? onResizeExternal; Future _handleInput(String data) async { - // If we press the `Enter` button on Android, - // `data` can be '\r' or '\n' when using different keyboards. - // Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline. - // Android -> Linux. Both '\r' and '\n' work as expected (execute a command). - // So when we receive '\n', we may need to convert it to '\r' to ensure compatibility. - // Desktop -> Desktop works fine. - // Check if we are on mobile or web(mobile), and convert '\n' to '\r'. + // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a + // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. + // - Peer Windows: '\r' works, '\n' is just a newline. + // - Peer Linux: canonical-mode shells accept both, but raw-mode apps + // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'. + // - Peer macOS: same as Linux, raw-mode apps expect '\r' + // (https://github.com/rustdesk/rustdesk/issues/14907). + // So on mobile / web-mobile, always normalize a lone '\n' to '\r'. + // We deliberately do not touch multi-character payloads (e.g. pasted text) + // so embedded newlines in pasted content are preserved. final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); - if (isMobileOrWebMobile && isPeerWindows && data == '\n') { + if (isMobileOrWebMobile && data == '\n') { data = '\r'; } if (_terminalOpened) { @@ -70,7 +75,10 @@ class TerminalModel with ChangeNotifier { terminalController = TerminalController(); // Setup terminal callbacks - terminal.onOutput = _handleInput; + terminal.onOutput = (data) { + if (_suppressTerminalOutput) return; + _handleInput(data); + }; terminal.onResize = (w, h, pw, ph) async { // Validate all dimensions before using them @@ -84,7 +92,7 @@ class TerminalModel with ChangeNotifier { // Mark terminal view as ready and flush any buffered output on first valid resize. // Must be after onResizeExternal so the view layer has valid dimensions before flushing. if (!_terminalViewReady) { - _markViewReady(); + _scheduleMarkViewReady(); } if (_terminalOpened) { @@ -110,14 +118,16 @@ class TerminalModel with ChangeNotifier { void onReady() { parent.dialogManager.dismissAll(); - // Fire and forget - don't block onReady - openTerminal().catchError((e) { + // Fire and forget - don't block onReady. If the transport reconnects while + // this model is still open, re-send OpenTerminal so the remote service marks + // the persistent session active again and resumes output streaming. + openTerminal(force: _terminalOpened).catchError((e) { debugPrint('[TerminalModel] Error opening terminal: $e'); }); } - Future openTerminal() async { - if (_terminalOpened) return; + Future openTerminal({bool force = false}) async { + if (_terminalOpened && !force) return; // Request the remote side to open a terminal with default shell // The remote side will decide which shell to use based on its OS @@ -275,9 +285,12 @@ class TerminalModel with ChangeNotifier { if (success) { _terminalOpened = true; - // On reconnect ("Reconnected to existing terminal"), server may replay recent output. - // If this TerminalView instance is reused (not rebuilt), duplicate lines can appear. - // We intentionally accept this tradeoff for now to keep logic simple. + // On reconnect, the server may replay recent output. That replay can include + // terminal queries like DSR/DA; xterm answers them through onOutput as + // "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer. + final replayTerminalOutput = evt['replay_terminal_output']; + _suppressNextTerminalDataOutput = replayTerminalOutput == true || + message == 'Reconnected to existing terminal with pending output'; // Fallback: if terminal view is not yet ready but already has valid // dimensions (e.g. layout completed before open response arrived), @@ -285,7 +298,7 @@ class TerminalModel with ChangeNotifier { if (!_terminalViewReady && terminal.viewWidth > 0 && terminal.viewHeight > 0) { - _markViewReady(); + _scheduleMarkViewReady(); } // Process any buffered input @@ -297,12 +310,16 @@ class TerminalModel with ChangeNotifier { }); final persistentSessions = - evt['persistent_sessions'] as List? ?? []; + (evt['persistent_sessions'] as List? ?? []) + .whereType() + .where((id) => !parent.terminalModels.containsKey(id)) + .toList(); if (kWindowId != null && persistentSessions.isNotEmpty) { DesktopMultiWindow.invokeMethod( kWindowId!, kWindowEventRestoreTerminalSessions, jsonEncode({ + 'peer_id': id, 'persistent_sessions': persistentSessions, })); } @@ -332,6 +349,8 @@ class TerminalModel with ChangeNotifier { final data = evt['data']; if (data != null) { + final suppressTerminalOutput = _suppressNextTerminalDataOutput; + _suppressNextTerminalDataOutput = false; try { String text = ''; if (data is String) { @@ -351,7 +370,7 @@ class TerminalModel with ChangeNotifier { return; } - _writeToTerminal(text); + _writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput); } catch (e) { debugPrint('[TerminalModel] Failed to process terminal data: $e'); } @@ -361,7 +380,10 @@ class TerminalModel with ChangeNotifier { /// Write text to terminal, buffering if the view is not yet ready. /// All terminal output should go through this method to avoid NaN errors /// from writing before the terminal view has valid layout dimensions. - void _writeToTerminal(String text) { + void _writeToTerminal( + String text, { + bool suppressTerminalOutput = false, + }) { if (!_terminalViewReady) { // If a single chunk exceeds the cap, keep only its tail. // Note: truncation may split a multi-byte ANSI escape sequence, @@ -373,34 +395,73 @@ class TerminalModel with ChangeNotifier { _pendingOutputChunks ..clear() ..add(truncated); + _pendingOutputSuppressFlags + ..clear() + ..add(suppressTerminalOutput); _pendingOutputSize = truncated.length; } else { _pendingOutputChunks.add(text); + _pendingOutputSuppressFlags.add(suppressTerminalOutput); _pendingOutputSize += text.length; // Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences) while (_pendingOutputSize > _kMaxOutputBufferChars && _pendingOutputChunks.length > 1) { final removed = _pendingOutputChunks.removeAt(0); + _pendingOutputSuppressFlags.removeAt(0); _pendingOutputSize -= removed.length; } } return; } - terminal.write(text); + _writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput); } void _flushOutputBuffer() { if (_pendingOutputChunks.isEmpty) return; debugPrint( '[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)'); - for (final chunk in _pendingOutputChunks) { - terminal.write(chunk); + for (var i = 0; i < _pendingOutputChunks.length; i++) { + _writeTerminalChunk( + _pendingOutputChunks[i], + suppressTerminalOutput: _pendingOutputSuppressFlags[i], + ); } _pendingOutputChunks.clear(); + _pendingOutputSuppressFlags.clear(); _pendingOutputSize = 0; } + void _writeTerminalChunk( + String text, { + required bool suppressTerminalOutput, + }) { + if (!suppressTerminalOutput) { + terminal.write(text); + return; + } + final previous = _suppressTerminalOutput; + _suppressTerminalOutput = true; + try { + terminal.write(text); + } finally { + _suppressTerminalOutput = previous; + } + } + /// Mark terminal view as ready and flush buffered output. + void _scheduleMarkViewReady() { + if (_disposed || _terminalViewReady || _markViewReadyScheduled) return; + _markViewReadyScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _markViewReadyScheduled = false; + if (_disposed || _terminalViewReady) return; + if (terminal.viewWidth > 0 && terminal.viewHeight > 0) { + _markViewReady(); + } + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + void _markViewReady() { if (_terminalViewReady) return; _terminalViewReady = true; @@ -426,7 +487,10 @@ class TerminalModel with ChangeNotifier { // Clear buffers to free memory _inputBuffer.clear(); _pendingOutputChunks.clear(); + _pendingOutputSuppressFlags.clear(); _pendingOutputSize = 0; + _markViewReadyScheduled = false; + _suppressNextTerminalDataOutput = false; // Terminal cleanup is handled server-side when service closes super.dispose(); } diff --git a/libs/hbb_common b/libs/hbb_common index 3e31a9493..42af0f0ae 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 3e31a94939e026ab2c05d21a2c436960aa9bfea8 +Subproject commit 42af0f0aed0bb5fd5df4ff95fd4cc9816fcf5769 diff --git a/src/flutter.rs b/src/flutter.rs index c7e07f892..f8b04bf6c 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1135,6 +1135,10 @@ impl InvokeUiSession for FlutterHandler { ("message", json!(&opened.message)), ("pid", json!(opened.pid)), ("service_id", json!(&opened.service_id)), + ( + "replay_terminal_output", + json!(opened.replay_terminal_output), + ), ]; if !opened.persistent_sessions.is_empty() { event_data.push(("persistent_sessions", json!(opened.persistent_sessions))); diff --git a/src/server/terminal_helper.rs b/src/server/terminal_helper.rs index 8edf4621b..fd85d2a4c 100644 --- a/src/server/terminal_helper.rs +++ b/src/server/terminal_helper.rs @@ -318,6 +318,35 @@ pub fn get_default_shell() -> String { std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) } +fn utf8_shell_args(shell: &str) -> Vec { + let name = std::path::Path::new(shell) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(shell) + .to_ascii_lowercase(); + + if name == "cmd.exe" || name == "cmd" { + return vec!["/K".to_string(), "chcp 65001 >NUL".to_string()]; + } + + if name == "pwsh.exe" || name == "pwsh" || name == "powershell.exe" { + return vec![ + "-NoLogo".to_string(), + "-NoExit".to_string(), + "-Command".to_string(), + "chcp.com 65001 > $null; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8".to_string(), + ]; + } + + Vec::new() +} + +pub fn configure_utf8_shell_command(shell: &str, cmd: &mut CommandBuilder) { + for arg in utf8_shell_args(shell) { + cmd.arg(arg); + } +} + /// Get the SID of the user from a token. /// Returns a Vec containing the SID bytes. pub fn get_user_sid_from_token(user_token: UserToken) -> Result> { @@ -831,7 +860,8 @@ pub fn run_terminal_helper(args: &[String]) -> Result<()> { let shell = get_default_shell(); log::debug!("Using shell: {}", shell); - let cmd = CommandBuilder::new(&shell); + let mut cmd = CommandBuilder::new(&shell); + configure_utf8_shell_command(&shell, &mut cmd); let mut child = pty_pair .slave .spawn_command(cmd) diff --git a/src/server/terminal_service.rs b/src/server/terminal_service.rs index fb6b4fd29..52a296b74 100644 --- a/src/server/terminal_service.rs +++ b/src/server/terminal_service.rs @@ -20,10 +20,11 @@ use std::{ // Windows-specific imports from terminal_helper module #[cfg(target_os = "windows")] use super::terminal_helper::{ - create_named_pipe_server, encode_helper_message, encode_resize_message, - is_helper_process_running, launch_terminal_helper_with_token, wait_for_pipe_connection, - HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle, WinTerminateProcess, - WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS, WIN_WAIT_OBJECT_0, + configure_utf8_shell_command, create_named_pipe_server, encode_helper_message, + encode_resize_message, is_helper_process_running, launch_terminal_helper_with_token, + wait_for_pipe_connection, HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle, + WinTerminateProcess, WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS, + WIN_WAIT_OBJECT_0, }; const MAX_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; // 1MB per terminal @@ -133,6 +134,26 @@ fn get_default_shell() -> String { } } +#[cfg(target_os = "macos")] +fn locale_value_is_utf8(value: &str) -> bool { + let value = value.to_ascii_uppercase(); + value.contains("UTF-8") || value.contains("UTF8") +} + +#[cfg(target_os = "macos")] +fn should_force_process_utf8_ctype() -> bool { + if let Ok(value) = std::env::var("LC_ALL") { + return !locale_value_is_utf8(&value); + } + if let Ok(value) = std::env::var("LC_CTYPE") { + return !locale_value_is_utf8(&value); + } + if let Ok(value) = std::env::var("LANG") { + return !locale_value_is_utf8(&value); + } + true +} + pub fn is_service_specified_user(service_id: &str) -> Option { get_service(service_id).map(|s| s.lock().unwrap().is_specified_user) } @@ -435,6 +456,7 @@ impl OutputBuffer { // Find first newline in new data if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') { last_line.extend_from_slice(&data[..=newline_pos]); + self.total_size += newline_pos + 1; start = newline_pos + 1; self.last_line_incomplete = false; } else { @@ -473,7 +495,28 @@ impl OutputBuffer { // Trim old data if buffer is too large while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES { if let Some(removed) = self.lines.pop_front() { - self.total_size -= removed.len(); + if removed.len() > self.total_size { + log::error!( + "OutputBuffer total_size underflow avoided: total_size={}, removed_len={}, lines_len={}", + self.total_size, + removed.len(), + self.lines.len() + ); + self.total_size = self.lines.iter().map(|line| line.len()).sum(); + } else { + self.total_size -= removed.len(); + } + if self.lines.is_empty() { + self.last_line_incomplete = false; + } + } else { + log::error!( + "OutputBuffer trim invariant broken: total_size={}, lines_len=0", + self.total_size + ); + self.total_size = 0; + self.last_line_incomplete = false; + break; } } } @@ -531,6 +574,97 @@ impl OutputBuffer { } } +/// Find the largest prefix of `buf` that does not end in the middle of a UTF-8 +/// code point. Invalid bytes are treated as complete so they can continue +/// downstream and be rendered with replacement characters if needed. +fn find_utf8_split_point(buf: &[u8]) -> usize { + if buf.is_empty() { + return 0; + } + + let start = buf.len().saturating_sub(3); + for i in (start..buf.len()).rev() { + let b = buf[i]; + if b & 0x80 == 0 { + return buf.len(); + } + if b & 0xC0 == 0x80 { + continue; + } + + let seq_len = if b & 0xE0 == 0xC0 { + 2 + } else if b & 0xF0 == 0xE0 { + 3 + } else if b & 0xF8 == 0xF0 { + 4 + } else { + return buf.len(); + }; + + return if buf.len() - i >= seq_len { + buf.len() + } else { + i + }; + } + + buf.len() +} + +// Terminal output currently follows a UTF-8 text model end to end: the service +// keeps replay buffers on UTF-8 boundaries, and Flutter decodes payload bytes as +// UTF-8 before writing to xterm. This accumulator only prevents splitting a +// trailing UTF-8 code point across PTY reads. Supporting non-UTF-8 terminals +// would need a separate design covering remote encoding detection, Flutter +// decoding, replay truncation, and input transcoding. +#[derive(Default)] +struct Utf8ChunkAccumulator { + remainder: Vec, +} + +impl Utf8ChunkAccumulator { + fn push_chunk(&mut self, mut data: Vec) -> Option> { + if data.is_empty() { + return None; + } + + let had_remainder = !self.remainder.is_empty(); + if had_remainder { + let mut combined = std::mem::take(&mut self.remainder); + combined.extend_from_slice(&data); + data = combined; + } + + let split = find_utf8_split_point(&data); + if split == data.len() { + return Some(data); + } + + // Only hold back a candidate incomplete suffix when we have evidence that + // the bytes before it are already UTF-8 text. If split is 0, the whole + // read may be the start of a UTF-8 character, so keep it for the next read. + if !had_remainder && split > 0 && std::str::from_utf8(&data[..split]).is_err() { + return Some(data); + } + + self.remainder = data.split_off(split); + if data.is_empty() { + None + } else { + Some(data) + } + } + + fn finish(&mut self) -> Option> { + if self.remainder.is_empty() { + None + } else { + Some(std::mem::take(&mut self.remainder)) + } + } +} + /// Try to send data through the output channel with rate-limited drop logging. /// Returns `true` if the caller should break out of the read loop (channel disconnected). fn try_send_output( @@ -570,7 +704,11 @@ fn try_send_output( false } Err(mpsc::TrySendError::Disconnected(_)) => { - log::debug!("Terminal {}{} output channel disconnected", terminal_id, label); + log::debug!( + "Terminal {}{} output channel disconnected", + terminal_id, + label + ); true } } @@ -937,15 +1075,35 @@ impl TerminalServiceProxy { if let Some(session_arc) = service.sessions.get(&open.terminal_id) { // Reconnect to existing terminal let mut session = session_arc.lock().unwrap(); - // Directly enter Active state with pending buffer for immediate streaming. - // Historical buffer is sent first by read_outputs(), then real-time data follows. - // No overlap: pending_buffer comes from output_buffer (pre-disconnect history), - // while received_data in read_outputs() comes from the channel (post-reconnect). - // During disconnect, the run loop (sp.ok()) exits so read_outputs() stops being - // called; output_buffer is not updated, and channel data may be lost if it fills up. - let buffer = session + // Directly enter Active state with pending replay for immediate streaming. + // The replay combines output_buffer history and the channel backlog that was + // already pending at reconnect time so the client can suppress stale xterm + // query answers without requiring a protobuf schema change. + // During disconnect, read_outputs() is not called; channel data can still be lost + // if output_rx fills before reconnect drains it. + let mut buffer = session .output_buffer .get_recent(DEFAULT_RECONNECT_BUFFER_BYTES); + let mut reconnect_backlog = Vec::new(); + if let Some(output_rx) = &session.output_rx { + // Cap reconnect-time drain so a chatty PTY cannot keep OpenTerminal + // inside this loop indefinitely. Remaining output is drained by read_outputs(). + for _ in 0..CHANNEL_BUFFER_SIZE { + let Ok(data) = output_rx.try_recv() else { + break; + }; + reconnect_backlog.push(data); + } + } + let has_reconnect_backlog = !reconnect_backlog.is_empty(); + for data in reconnect_backlog { + session.output_buffer.append(&data); + } + if has_reconnect_backlog { + buffer = session + .output_buffer + .get_recent(DEFAULT_RECONNECT_BUFFER_BYTES); + } let has_pending = !buffer.is_empty(); session.state = SessionState::Active { pending_buffer: if has_pending { Some(buffer) } else { None }, @@ -959,9 +1117,14 @@ impl TerminalServiceProxy { let mut opened = TerminalOpened::new(); opened.terminal_id = open.terminal_id; opened.success = true; - opened.message = "Reconnected to existing terminal".to_string(); + opened.message = if has_pending { + "Reconnected to existing terminal with pending output".to_string() + } else { + "Reconnected to existing terminal".to_string() + }; opened.pid = session.pid; opened.service_id = self.service_id.clone(); + opened.replay_terminal_output = has_pending; if service.needs_session_sync { if service.sessions.len() > 1 { // No need to include the current terminal in the list. @@ -1016,6 +1179,9 @@ impl TerminalServiceProxy { #[allow(unused_mut)] let mut cmd = CommandBuilder::new(&shell); + #[cfg(target_os = "windows")] + configure_utf8_shell_command(&shell, &mut cmd); + // macOS-specific terminal configuration // 1. Use login shell (-l) to load user's shell profile (~/.zprofile, ~/.bash_profile) // This ensures PATH includes Homebrew paths (/opt/homebrew/bin, /usr/local/bin) @@ -1036,6 +1202,12 @@ impl TerminalServiceProxy { }; cmd.env("TERM", term); log::debug!("Set TERM={} for macOS PTY", term); + + if should_force_process_utf8_ctype() { + cmd.env_remove("LC_ALL"); + cmd.env("LC_CTYPE", "en_US.UTF-8"); + log::debug!("Set LC_CTYPE=en_US.UTF-8 for macOS PTY"); + } } // Note: On Windows with user_token, we use helper mode (handle_open_with_helper) @@ -1086,6 +1258,7 @@ impl TerminalServiceProxy { let reader_thread = thread::spawn(move || { let mut reader = reader; let mut buf = vec![0u8; 4096]; + let mut utf8_chunks = Utf8ChunkAccumulator::default(); let mut drop_count: u64 = 0; // Initialize to > 5s ago so the first drop triggers a warning immediately. let mut last_drop_warn = Instant::now() - Duration::from_secs(6); @@ -1095,13 +1268,25 @@ impl TerminalServiceProxy { // EOF // This branch can be reached when the child process exits on macOS. // But not on Linux and Windows in my tests. + if let Some(data) = utf8_chunks.finish() { + let _ = try_send_output( + &output_tx, + data, + terminal_id, + "", + &mut drop_count, + &mut last_drop_warn, + ); + } break; } Ok(n) => { if exiting.load(Ordering::SeqCst) { break; } - let data = buf[..n].to_vec(); + let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else { + continue; + }; // Use try_send to avoid blocking the reader thread when channel is full. // During disconnect, the run loop (sp.ok()) stops and read_outputs() is // no longer called, so the channel won't be drained. Blocking send would @@ -1308,12 +1493,23 @@ impl TerminalServiceProxy { let terminal_id = open.terminal_id; let reader_thread = thread::spawn(move || { let mut buf = vec![0u8; 4096]; + let mut utf8_chunks = Utf8ChunkAccumulator::default(); let mut drop_count: u64 = 0; // Initialize to > 5s ago so the first drop triggers a warning immediately. let mut last_drop_warn = Instant::now() - Duration::from_secs(6); loop { match output_pipe.read(&mut buf) { Ok(0) => { + if let Some(data) = utf8_chunks.finish() { + let _ = try_send_output( + &output_tx, + data, + terminal_id, + " (helper)", + &mut drop_count, + &mut last_drop_warn, + ); + } // EOF - helper process exited log::debug!("Terminal {} helper output EOF", terminal_id); break; @@ -1322,7 +1518,9 @@ impl TerminalServiceProxy { if exiting.load(Ordering::SeqCst) { break; } - let data = buf[..n].to_vec(); + let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else { + continue; + }; // Use try_send to avoid blocking the reader thread (same as direct PTY mode) if try_send_output( &output_tx, @@ -1462,20 +1660,28 @@ impl TerminalServiceProxy { data: &TerminalData, ) -> Result> { if let Some(session_arc) = session { - let mut session = session_arc.lock().unwrap(); - session.update_activity(); - if let Some(input_tx) = &session.input_tx { - // Encode data for helper mode or send raw for direct PTY mode - #[cfg(target_os = "windows")] - let msg = if session.is_helper_mode { - encode_helper_message(MSG_TYPE_DATA, &data.data) - } else { - data.data.to_vec() - }; - #[cfg(not(target_os = "windows"))] - let msg = data.data.to_vec(); + let input = { + let mut session = session_arc.lock().unwrap(); + session.update_activity(); + if let Some(input_tx) = session.input_tx.clone() { + // Encode data for helper mode or send raw for direct PTY mode + #[cfg(target_os = "windows")] + let msg = if session.is_helper_mode { + encode_helper_message(MSG_TYPE_DATA, &data.data) + } else { + data.data.to_vec() + }; + #[cfg(not(target_os = "windows"))] + let msg = data.data.to_vec(); - // Send data to writer thread + Some((input_tx, msg)) + } else { + None + } + }; + + if let Some((input_tx, msg)) = input { + // Send outside the session lock; SyncSender::send can block when full. if let Err(e) = input_tx.send(msg) { log::error!( "Failed to send data to terminal {}: {}", @@ -1683,10 +1889,6 @@ impl TerminalServiceProxy { } } - if has_activity { - session.update_activity(); - } - // Update buffer (always buffer for reconnection support) for data in &received_data { session.output_buffer.append(data); @@ -1696,7 +1898,7 @@ impl TerminalServiceProxy { // Data is already buffered above and will be sent on next reconnection. // Use a scoped block to limit the mutable borrow of session.state, // so we can immutably borrow other session fields afterwards. - let sigwinch_action = { + let (replay_buffer, sigwinch_action) = { let (pending_buffer, sigwinch) = match &mut session.state { SessionState::Active { pending_buffer, @@ -1705,19 +1907,12 @@ impl TerminalServiceProxy { _ => continue, }; - // Send pending buffer response first (set on reconnection in handle_open). - // This ensures historical buffer is sent before any real-time data. - if let Some(buffer) = pending_buffer.take() { - if !buffer.is_empty() { - responses - .push(Self::create_terminal_data_response(terminal_id, buffer)); - } - } + let replay_buffer = pending_buffer.take(); // Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale. // Each phase is a single PTY resize, spaced ~30ms apart by the polling // interval, ensuring the TUI app sees a real size change on each signal. - match sigwinch { + let sigwinch_action = match sigwinch { SigwinchPhase::TempResize { retries } => { if *retries == 0 { log::warn!( @@ -1745,9 +1940,20 @@ impl TerminalServiceProxy { } } SigwinchPhase::Idle => None, - } + }; + (replay_buffer, sigwinch_action) }; + if let Some(buffer) = replay_buffer { + if !buffer.is_empty() { + responses.push(Self::create_terminal_data_response(terminal_id, buffer)); + } + } + + if has_activity { + session.update_activity(); + } + // Execute SIGWINCH resize outside the mutable borrow scope of session.state. if let Some(action) = sigwinch_action { #[cfg(target_os = "windows")] @@ -1845,3 +2051,116 @@ impl TerminalServiceProxy { } } } + +#[cfg(test)] +mod tests { + use super::{find_utf8_split_point, OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES}; + + #[test] + fn utf8_split_point_returns_full_len_for_complete_input() { + assert_eq!(find_utf8_split_point(b"hello"), 5); + assert_eq!(find_utf8_split_point("中文".as_bytes()), "中文".len()); + assert_eq!(find_utf8_split_point("😀".as_bytes()), "😀".len()); + } + + #[test] + fn utf8_split_point_detects_incomplete_trailing_sequence() { + let data = [b'a', 0xE4, 0xB8]; + assert_eq!(find_utf8_split_point(&data), 1); + } + + #[test] + fn utf8_split_point_keeps_malformed_prefix_but_buffers_trailing_lead_byte() { + let data = [0xFF, 0xE4]; + assert_eq!(find_utf8_split_point(&data), 1); + } + + #[test] + fn utf8_split_point_treats_orphan_continuations_as_complete() { + let data = [0x80, 0x81, 0x82]; + assert_eq!(find_utf8_split_point(&data), data.len()); + } + + #[test] + fn utf8_chunk_accumulator_reassembles_split_multibyte_output() { + let full = "你好世界".as_bytes(); + let mut chunker = Utf8ChunkAccumulator::default(); + let mut output = Vec::new(); + + for chunk in full.chunks(5) { + if let Some(data) = chunker.push_chunk(chunk.to_vec()) { + output.extend_from_slice(&data); + } + } + + if let Some(data) = chunker.finish() { + output.extend_from_slice(&data); + } + + assert_eq!(output, full); + } + + #[test] + fn utf8_chunk_accumulator_buffers_leading_split_multibyte_output() { + let mut chunker = Utf8ChunkAccumulator::default(); + + assert!(chunker.push_chunk(vec![0xE4]).is_none()); + assert!(chunker.push_chunk(vec![0xB8]).is_none()); + assert_eq!( + chunker.push_chunk(vec![0xAD]), + Some("中".as_bytes().to_vec()) + ); + assert!(chunker.finish().is_none()); + } + + #[test] + fn utf8_chunk_accumulator_flushes_incomplete_tail_on_finish() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert_eq!(chunker.push_chunk(vec![b'a', 0xE4]), Some(vec![b'a'])); + assert_eq!(chunker.finish(), Some(vec![0xE4])); + assert!(chunker.finish().is_none()); + } + + #[test] + fn utf8_chunk_accumulator_does_not_stall_on_malformed_bytes() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert_eq!(chunker.push_chunk(vec![0xFF]), Some(vec![0xFF])); + assert!(chunker.finish().is_none()); + } + + #[test] + fn utf8_chunk_accumulator_buffers_lone_utf8_lead_bytes() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert!(chunker.push_chunk(vec![0xE4]).is_none()); + assert_eq!(chunker.finish(), Some(vec![0xE4])); + } + + #[test] + fn utf8_chunk_accumulator_does_not_hold_back_non_utf8_prefixes() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert_eq!(chunker.push_chunk(vec![0xFF, 0xE4]), Some(vec![0xFF, 0xE4])); + assert!(chunker.finish().is_none()); + } + + #[test] + fn output_buffer_trim_after_incomplete_merge_does_not_underflow() { + let mut buffer = OutputBuffer::new(); + + // Create an incomplete line first. + buffer.append(b"hello"); + + // Merge a large chunk that contains the first newline at the tail. + // This exercises the "append to last incomplete line" branch. + let mut large = vec![b'a'; 30_000]; + large.push(b'\n'); + buffer.append(&large); + + // Exceed MAX_BUFFER_LINES so trim pops the first large merged line. + for _ in 0..=MAX_BUFFER_LINES { + buffer.append(b"x\n"); + } + + let actual_size: usize = buffer.lines.iter().map(|line| line.len()).sum(); + assert_eq!(buffer.total_size, actual_size); + } +} From 72d27c3c47b0d081ec35aedbadf226d7a1b9bf0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?VenusGirl=E2=9D=A4?= Date: Fri, 8 May 2026 18:49:17 +0900 Subject: [PATCH 15/36] Update Korean (#14956) --- src/lang/ko.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 7b3ffd98e..de68574e1 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.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 9df486a689dbee26ba9868c68131d6a627018fba Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 18:15:00 +0800 Subject: [PATCH 16/36] fix(ipc): harden local IPC authorization and portable-service bootstrap flow (#14671) * fix(ipc): harden ipc access Signed-off-by: fufesou * fix(ipc): full cmd path, comments, simple refactor Signed-off-by: fufesou * fix(ipc): portable service, ipc exit Signed-off-by: fufesou * fix(ipc): Remove unused logs Signed-off-by: fufesou * fix(ipc): Use SetEntriesInAclW instead of icacls Signed-off-by: fufesou * fix(ipc): Comments Signed-off-by: fufesou * fix(ipc): check is_reparse_point Signed-off-by: fufesou * fix(ipc): shmem name, no fallback Signed-off-by: fufesou * fix(ipc): Simple refactor Signed-off-by: fufesou * fix(ipc): better exit and clear Signed-off-by: fufesou * fix(ipc): portable service, better exit Signed-off-by: fufesou * fix(ipc): comments, id -u Signed-off-by: fufesou * fix: comments linux headless, rx desktop ready Signed-off-by: fufesou * fix(ipc): magic number Signed-off-by: fufesou * fix(ipc): update deps Signed-off-by: fufesou * Update Cargo.lock * Update Cargo.lock * fix(ipc): harden ipc, test `identity_unavailable` Signed-off-by: fufesou * fix(ipc): portable service, check dir of shmem Signed-off-by: fufesou * fix(ipc): macos, better check exe allowed Signed-off-by: fufesou * fix(ipc): update hbb_common Signed-off-by: fufesou * fix(ipc): update hbb_common Signed-off-by: fufesou * fix(ipc): harden ipc, better active uid for uinput Signed-off-by: fufesou * fix(ipc): harden portable service token validation Compare portable service IPC tokens in constant time and document the CSPRNG source used for one-time token generation. Clarify Windows IPC authorization comments around canonical path matching and partial peer identity lookup. Signed-off-by: fufesou * fix(ipc): simple refactor Signed-off-by: fufesou * fix(ipc): harden portable service token handling Generate the portable service IPC token directly from OsRng, keep token comparison in the IPC layer as a fixed-length byte-wise check, and document the malformed-frame behavior for protected service IPC. Signed-off-by: fufesou * fix(ipc): comments Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> --- Cargo.lock | 4 +- src/core_main.rs | 8 +- src/ipc.rs | 467 +++++++++++--- src/ipc/auth.rs | 1036 ++++++++++++++++++++++++++++++++ src/ipc/fs.rs | 951 +++++++++++++++++++++++++++++ src/platform/linux.rs | 51 ++ src/platform/windows.rs | 320 +++++++++- src/platform/windows/acl.rs | 903 ++++++++++++++++++++++++++++ src/server.rs | 10 +- src/server/connection.rs | 162 +++-- src/server/portable_service.rs | 790 +++++++++++++++++++++--- src/server/uinput.rs | 47 +- 12 files changed, 4500 insertions(+), 249 deletions(-) create mode 100644 src/ipc/auth.rs create mode 100644 src/ipc/fs.rs create mode 100644 src/platform/windows/acl.rs diff --git a/Cargo.lock b/Cargo.lock index febfd6b17..fe1f67cc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5996,8 +5996,8 @@ dependencies = [ [[package]] name = "parity-tokio-ipc" -version = "0.7.3-5" -source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291" +version = "0.7.3-6" +source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01" dependencies = [ "futures", "libc", diff --git a/src/core_main.rs b/src/core_main.rs index e27091927..67a83a37e 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -146,7 +146,13 @@ pub fn core_main() -> Option> { crate::portable_service::client::set_quick_support(_is_quick_support); } let mut log_name = "".to_owned(); - if args.len() > 0 && args[0].starts_with("--") { + // Keep portable-service logs under a stable directory name. + let has_portable_service_shmem_arg = args + .iter() + .any(|arg| arg.starts_with("--portable-service-shmem-name=")); + if has_portable_service_shmem_arg { + log_name = "portable-service".to_owned(); + } else if args.len() > 0 && args[0].starts_with("--") { let name = args[0].replace("--", ""); if !name.is_empty() { log_name = name; diff --git a/src/ipc.rs b/src/ipc.rs index 82b52a60c..0258a2816 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,33 +1,28 @@ -use crate::{ - common::CheckTestNatType, - privacy_mode::PrivacyModeState, - ui_interface::{get_local_option, set_local_option}, -}; -use bytes::Bytes; -use parity_tokio_ipc::{ - Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, -}; -use serde_derive::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - sync::atomic::{AtomicBool, Ordering}, -}; -#[cfg(not(windows))] -use std::{fs::File, io::prelude::*}; +#[path = "ipc/auth.rs"] +mod ipc_auth; +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[path = "ipc/fs.rs"] +mod ipc_fs; #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::plugin::ipc::Plugin; +use crate::{ + common::{is_server, CheckTestNatType}, + privacy_mode, + privacy_mode::PrivacyModeState, + rendezvous_mediator::RendezvousMediator, + ui_interface::{get_local_option, set_local_option}, +}; +use bytes::Bytes; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use clipboard::ClipboardFile; +#[cfg(target_os = "linux")] +use hbb_common::anyhow; use hbb_common::{ allow_err, bail, bytes, bytes_codec::BytesCodec, - config::{ - self, - keys::{self, OPTION_ALLOW_WEBSOCKET}, - Config, Config2, - }, + config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2}, futures::StreamExt as _, futures_util::sink::SinkExt, log, password_security as password, timeout, @@ -38,13 +33,55 @@ use hbb_common::{ tokio_util::codec::Framed, ResultType, }; - -use crate::{common::is_server, privacy_mode, rendezvous_mediator::RendezvousMediator}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_auth::authorize_service_scoped_ipc_connection; +#[cfg(windows)] +pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection; +#[cfg(windows)] +pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; +#[cfg(windows)] +pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; +#[cfg(target_os = "linux")] +pub(crate) use ipc_auth::{ + active_uid, ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, + log_rejected_uinput_connection, peer_uid_from_fd, +}; +#[cfg(windows)] +use ipc_auth::{ + authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, + should_allow_everyone_create_on_windows, +}; +#[cfg(target_os = "linux")] +use ipc_fs::terminal_count_candidate_uids; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_fs::{ + check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, + should_scrub_parent_entries_after_check_pid, write_pid, +}; +use parity_tokio_ipc::{ + Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, +}; +use serde_derive::{Deserialize, Serialize}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::fs::PermissionsExt; +use std::{ + collections::HashMap, + sync::atomic::{AtomicBool, Ordering}, +}; // IPC actions here. pub const IPC_ACTION_CLOSE: &str = "close"; +const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000; +pub(crate) const IPC_TOKEN_LEN: usize = 64; +const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2; +const _: () = assert!(IPC_TOKEN_LEN % 2 == 0); pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true); +#[inline] +pub async fn connect_service(ms_timeout: u64) -> ResultType> { + connect(ms_timeout, crate::POSTFIX_SERVICE).await +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum FS { @@ -207,6 +244,8 @@ pub enum DataControl { pub enum DataPortableService { Ping, Pong, + AuthToken(String), + AuthResult(bool), ConnCount(Option), Mouse((Vec, i32, String, u32, bool, bool)), Pointer((Vec, i32)), @@ -411,6 +450,22 @@ pub async fn start(postfix: &str) -> ResultType<()> { Ok(stream) => { let mut stream = Connection::new(stream); let postfix = postfix.to_owned(); + #[cfg(any(target_os = "linux", target_os = "macos"))] + if config::is_service_ipc_postfix(&postfix) { + if !authorize_service_scoped_ipc_connection(&stream, &postfix) { + continue; + } + } + #[cfg(windows)] + if postfix.is_empty() { + // Windows main IPC (`postfix == ""`) is authorized here. + // Other security-sensitive channels use dedicated authorization paths: + // - `_portable_service`: portable-service listener + handshake policy + // - service-scoped postfixes: service-specific listener/authorization + if !authorize_windows_main_ipc_connection(&stream, &postfix) { + continue; + } + } tokio::spawn(async move { loop { match stream.next().await { @@ -419,9 +474,48 @@ pub async fn start(postfix: &str) -> ResultType<()> { break; } Ok(Some(data)) => { + // On Linux/macOS, the protected `_service` channel is used only for + // syncing config between root service and the active user process. + // + // NOTE: `is_service_ipc_postfix()` also includes `_uinput_*`, but those + // channels are handled by the dedicated uinput listener/protocol in + // `src/server/uinput.rs` and therefore do not share this Data enum + // allowlist. The SyncConfig allowlist here is intentionally scoped to the + // `_service` channel only. + // + // Keep this explicit branch to avoid policy drift between `_service` and + // uinput IPC paths while still minimizing exposed message surface here. + #[cfg(any(target_os = "linux", target_os = "macos"))] + if postfix == crate::POSTFIX_SERVICE { + if matches!(&data, Data::SyncConfig(_)) { + handle(data, &mut stream).await; + } else { + log::warn!( + "Rejected non-sync data on protected _service IPC channel: postfix={}, data_kind={:?}, peer_uid={:?}", + postfix, + std::mem::discriminant(&data), + stream.peer_uid() + ); + // Close the connection to avoid keeping a protected channel + // alive while repeatedly receiving invalid traffic. + break; + } + continue; + } handle(data, &mut stream).await; } - _ => {} + Ok(None) => { + // `Ok(None)` means a complete frame arrived but did not + // deserialize into `Data`. Peer close/reset is returned as + // `Err` by `ConnectionTmpl::next()`. Keep the historical + // ignore behavior except on the protected `_service` channel. + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + if postfix == crate::POSTFIX_SERVICE { + break; + } + } + } } } }); @@ -436,20 +530,77 @@ pub async fn start(postfix: &str) -> ResultType<()> { pub async fn new_listener(postfix: &str) -> ResultType { let path = Config::ipc_path(postfix); - #[cfg(not(any(windows, target_os = "android", target_os = "ios")))] - check_pid(postfix).await; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let should_scrub_parent_entries = ensure_secure_ipc_parent_dir(&path, postfix)?; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let existing_listener_alive = check_pid(postfix).await; + #[cfg(any(target_os = "linux", target_os = "macos"))] + if should_scrub_parent_entries_after_check_pid( + should_scrub_parent_entries, + existing_listener_alive, + ) { + scrub_secure_ipc_parent_dir(&path, postfix)?; + } let mut endpoint = Endpoint::new(path.clone()); - match SecurityAttributes::allow_everyone_create() { + let security_attrs = { + #[cfg(windows)] + { + if postfix == "_portable_service" { + portable_service_listener_security_attributes() + } else if should_allow_everyone_create_on_windows(postfix) { + SecurityAttributes::allow_everyone_create() + } else { + Ok(SecurityAttributes::empty()) + } + } + #[cfg(not(windows))] + { + SecurityAttributes::allow_everyone_create() + } + }; + match security_attrs { Ok(attr) => endpoint.set_security_attributes(attr), - Err(err) => log::error!("Failed to set ipc{} security: {}", postfix, err), + Err(err) => { + log::error!("Failed to set ipc{} security: {}", postfix, err); + #[cfg(windows)] + if postfix == "_portable_service" { + // Fail closed for `_portable_service` when SDDL construction fails. + // This endpoint is security-critical and must not start with default ACLs. + return Err(err.into()); + } + } }; match endpoint.incoming() { Ok(incoming) => { - log::info!("Started ipc{} server at path: {}", postfix, &path); - #[cfg(not(windows))] + if postfix == crate::POSTFIX_SERVICE { + log::info!("Started protected ipc service server: postfix={}", postfix); + } else { + log::info!("Started ipc{} server at path: {}", postfix, &path); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok(); + // NOTE: On Linux/macOS, some IPC sockets are intentionally world-connectable + // (0666) so the active (non-root) user process can connect. Authorization is + // enforced at accept-time for these channels, and the protected `_service` + // channel is further restricted by an explicit message allowlist (SyncConfig + // only). + let socket_mode = if config::is_service_ipc_postfix(postfix) { + 0o0666 + } else { + 0o0600 + }; + if let Err(err) = + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(socket_mode)) + { + log::error!( + "Failed to set permissions on ipc{} socket at path {}: {}", + postfix, + &path, + err + ); + std::fs::remove_file(&path).ok(); + return Err(err.into()); + } write_pid(postfix); } Ok(incoming) @@ -953,15 +1104,116 @@ async fn handle(data: Data, stream: &mut Connection) { ); } _ => {} - } + }; } pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { let path = Config::ipc_path(postfix); - let client = timeout(ms_timeout, Endpoint::connect(&path)).await??; + connect_with_path(ms_timeout, &path).await +} + +pub(crate) fn generate_one_time_ipc_token() -> ResultType { + use hbb_common::rand::{rngs::OsRng, RngCore as _}; + use std::fmt::Write as _; + + let mut random_bytes = [0u8; IPC_TOKEN_RANDOM_BYTES]; + let mut rng = OsRng; + rng.try_fill_bytes(&mut random_bytes).map_err(|err| { + hbb_common::anyhow::anyhow!( + "failed to generate portable service ipc token from OsRng: {}", + err + ) + })?; + + let mut token = String::with_capacity(IPC_TOKEN_LEN); + for byte in random_bytes { + let _ = write!(token, "{:02x}", byte); + } + Ok(token) +} + +pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool { + if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN { + return false; + } + expected + .as_bytes() + .iter() + .zip(candidate.as_bytes().iter()) + .fold(0u8, |diff, (left, right)| diff | (*left ^ *right)) + == 0 +} + +pub(crate) async fn portable_service_ipc_handshake_as_client( + stream: &mut ConnectionTmpl, + token: &str, +) -> ResultType<()> +where + T: AsyncRead + AsyncWrite + std::marker::Unpin, +{ + stream + .send(&Data::DataPortableService(DataPortableService::AuthToken( + token.to_owned(), + ))) + .await?; + match stream + .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) + .await? + { + Some(Data::DataPortableService(DataPortableService::AuthResult(true))) => Ok(()), + Some(Data::DataPortableService(DataPortableService::AuthResult(false))) => { + bail!("portable service ipc handshake was rejected by server") + } + Some(_) | None => bail!("portable service ipc handshake returned an unexpected response"), + } +} + +pub(crate) async fn portable_service_ipc_handshake_as_server( + stream: &mut ConnectionTmpl, + mut validate_token: F, +) -> ResultType<()> +where + T: AsyncRead + AsyncWrite + std::marker::Unpin, + // Token validators must use `constant_time_ipc_token_eq` or an equivalent + // fixed-length comparison; this handshake is part of the privilege boundary. + F: FnMut(&str) -> bool, +{ + let authorized = match stream + .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) + .await? + { + Some(Data::DataPortableService(DataPortableService::AuthToken(token))) => { + validate_token(&token) + } + Some(_) | None => false, + }; + stream + .send(&Data::DataPortableService(DataPortableService::AuthResult( + authorized, + ))) + .await?; + if !authorized { + bail!("portable service ipc handshake failed") + } + Ok(()) +} + +#[inline] +async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType> { + let client = timeout(ms_timeout, Endpoint::connect(path)).await??; Ok(ConnectionTmpl::new(client)) } +#[cfg(target_os = "linux")] +pub async fn connect_for_uid( + ms_timeout: u64, + uid: u32, + postfix: &str, +) -> ResultType> { + let path = Config::ipc_path_for_uid(uid, postfix); + connect_with_path(ms_timeout, &path).await +} + #[cfg(target_os = "linux")] #[tokio::main(flavor = "current_thread")] pub async fn start_pa() { @@ -1039,54 +1291,6 @@ pub async fn start_pa() { } } -#[inline] -#[cfg(not(windows))] -fn get_pid_file(postfix: &str) -> String { - let path = Config::ipc_path(postfix); - format!("{}.pid", path) -} - -#[cfg(not(any(windows, target_os = "android", target_os = "ios")))] -async fn check_pid(postfix: &str) { - let pid_file = get_pid_file(postfix); - if let Ok(mut file) = File::open(&pid_file) { - let mut content = String::new(); - file.read_to_string(&mut content).ok(); - let pid = content.parse::().unwrap_or(0); - if pid > 0 { - use hbb_common::sysinfo::System; - let mut sys = System::new(); - sys.refresh_processes(); - if let Some(p) = sys.process(pid.into()) { - if let Some(current) = sys.process((std::process::id() as usize).into()) { - if current.name() == p.name() { - // double check with connect - if connect(1000, postfix).await.is_ok() { - return; - } - } - } - } - } - } - // if not remove old ipc file, the new ipc creation will fail - // if we remove a ipc file, but the old ipc process is still running, - // new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive - std::fs::remove_file(&Config::ipc_path(postfix)).ok(); -} - -#[inline] -#[cfg(not(windows))] -fn write_pid(postfix: &str) { - let path = get_pid_file(postfix); - if let Ok(mut file) = File::create(&path) { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok(); - file.write_all(&std::process::id().to_string().into_bytes()) - .ok(); - } -} - pub struct ConnectionTmpl { inner: Framed, } @@ -1550,9 +1754,10 @@ pub fn close_all_instances() -> ResultType { } } +#[cfg(windows)] #[tokio::main(flavor = "current_thread")] pub async fn connect_to_user_session(usid: Option) -> ResultType<()> { - let mut stream = crate::ipc::connect(1000, crate::POSTFIX_SERVICE).await?; + let mut stream = crate::ipc::connect_service(1000).await?; timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??; Ok(()) } @@ -1678,13 +1883,76 @@ pub async fn update_controlling_session_count(count: usize) -> ResultType<()> { #[cfg(target_os = "linux")] #[tokio::main(flavor = "current_thread")] pub async fn get_terminal_session_count() -> ResultType { - let ms_timeout = 1_000; - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::TerminalSessionCount(0)).await?; - if let Some(Data::TerminalSessionCount(c)) = c.next_timeout(ms_timeout).await? { - return Ok(c); + let timeout_ms = 1_000; + let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let candidate_uids = terminal_count_candidate_uids(effective_uid); + let mut last_err: Option = None; + for candidate_uid in candidate_uids { + let socket_path = Config::ipc_path_for_uid(candidate_uid, ""); + let connect_result = timeout(timeout_ms, Endpoint::connect(&socket_path)) + .await + .map_err(|err| { + anyhow::anyhow!( + "Timeout connecting to terminal ipc at {}: {}", + socket_path, + err + ) + }); + let connection = match connect_result { + Ok(Ok(connection)) => connection, + Ok(Err(err)) => { + last_err = Some(anyhow::anyhow!( + "Failed to connect to terminal ipc at {}: {}", + socket_path, + err + )); + continue; + } + Err(err) => { + last_err = Some(err); + continue; + } + }; + let mut ipc_conn = ConnectionTmpl::new(connection); + if let Err(err) = ipc_conn.send(&Data::TerminalSessionCount(0)).await { + last_err = Some(anyhow::anyhow!( + "Failed to request terminal session count via ipc at {}: {}", + socket_path, + err + )); + continue; + } + match ipc_conn.next_timeout(timeout_ms).await { + Ok(Some(Data::TerminalSessionCount(session_count))) => { + return Ok(session_count); + } + Ok(None) => { + last_err = Some(anyhow::anyhow!( + "Invalid response when requesting terminal session count via ipc at {}", + socket_path + )); + } + Ok(other) => { + last_err = Some(anyhow::anyhow!( + "Unexpected response when requesting terminal session count via ipc at {}: {:?}", + socket_path, + other.map(|v| std::mem::discriminant(&v)) + )); + } + Err(err) => { + last_err = Some(anyhow::anyhow!( + "Failed to read terminal session count via ipc at {}: {}", + socket_path, + err + )); + } + } + } + if let Some(err) = last_err { + Err(err.into()) + } else { + Ok(0) } - Ok(0) } async fn handle_wayland_screencast_restore_token( @@ -1715,9 +1983,30 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> { #[cfg(test)] mod test { use super::*; + #[test] fn verify_ffi_enum_data_size() { println!("{}", std::mem::size_of::()); assert!(std::mem::size_of::() <= 120); } + + #[cfg(target_os = "linux")] + #[test] + fn test_ipc_path_differs_by_uid_for_cm() { + let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let other_uid = effective_uid.saturating_add(1); + let postfix = "_cm"; + + // Default connect path targets the current effective uid. + assert_eq!( + Config::ipc_path(postfix), + Config::ipc_path_for_uid(effective_uid, postfix) + ); + // A different uid yields a different socket path - this is the root cause of the + // cross-user regression when root spawns a user process but still connects as uid 0. + assert_ne!( + Config::ipc_path(postfix), + Config::ipc_path_for_uid(other_uid, postfix) + ); + } } diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs new file mode 100644 index 000000000..746a32eed --- /dev/null +++ b/src/ipc/auth.rs @@ -0,0 +1,1036 @@ +use crate::ipc::{Connection, ConnectionTmpl}; +#[cfg(all(windows, not(feature = "flutter")))] +use hbb_common::sha2::{Digest, Sha256}; +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +use hbb_common::{anyhow, bail, log, ResultType}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use hbb_common::{ + libc, + tokio::io::{AsyncRead, AsyncWrite}, +}; +#[cfg(windows)] +use parity_tokio_ipc::SecurityAttributes; +#[cfg(windows)] +use std::io; +#[cfg(all(windows, not(feature = "flutter")))] +use std::io::Read; +#[cfg(target_os = "macos")] +use std::os::unix::fs::MetadataExt; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::io::RawFd; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +use std::{ + fs, + path::{Path, PathBuf}, + sync::{Mutex, OnceLock}, +}; +#[cfg(windows)] +use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId}; + +#[cfg(windows)] +#[inline] +pub(crate) fn should_allow_everyone_create_on_windows(postfix: &str) -> bool { + postfix.is_empty() || hbb_common::config::is_service_ipc_postfix(postfix) +} + +#[cfg(windows)] +#[inline] +pub(crate) fn portable_service_listener_security_attributes() -> io::Result { + let user_sid = crate::platform::windows::current_process_user_sid_string().map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!("failed to resolve current process SID: {}", err), + ) + })?; + debug_assert!( + user_sid.starts_with("S-1-") + && user_sid + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'-'), + "current_process_user_sid_string returned a non-SDDL SID: {}", + user_sid + ); + // SDDL: + // - `D:P` => protected DACL (no inherited ACEs) + // - `(A;;GA;;;SY)` => allow GENERIC_ALL to LocalSystem + // - `(A;;GA;;;{user_sid})` => allow GENERIC_ALL to current process user SID + // References: + // - Security Descriptor String Format: https://learn.microsoft.com/en-us/windows/win32/secauthz/security-descriptor-string-format + // - ACE strings in SDDL: https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-strings + let sddl = format!("D:P(A;;GA;;;SY)(A;;GA;;;{user_sid})"); + SecurityAttributes::from_sddl(&sddl).map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!( + "failed to build portable service listener security attributes from SDDL '{}': {}", + sddl, err + ), + ) + }) +} + +#[cfg(target_os = "macos")] +#[inline] +fn macos_service_ipc_allows_gui_and_service_binaries( + peer_exe: &Path, + current_exe: &Path, + postfix: &str, +) -> bool { + if postfix != crate::POSTFIX_SERVICE { + return false; + } + let Some(peer_dir) = peer_exe.parent() else { + return false; + }; + let Some(current_dir) = current_exe.parent() else { + return false; + }; + if !executable_paths_match(peer_dir, current_dir) { + return false; + } + + // On installed macOS builds, `_service` is listened by the `service` binary while the GUI + // process connects from the app executable within the same app bundle. + let gui_exe_name = std::ffi::OsString::from(crate::get_app_name()); + let gui_exe = gui_exe_name.as_os_str(); + let service_exe = std::ffi::OsStr::new("service"); + let allowed_exe = [Some(gui_exe), Some(service_exe)]; + let peer_name = peer_exe.file_name(); + let current_name = current_exe.file_name(); + allowed_exe + .iter() + .any(|name| os_str_eq_ignore_ascii_case(peer_name, *name)) + && allowed_exe + .iter() + .any(|name| os_str_eq_ignore_ascii_case(current_name, *name)) +} + +#[cfg(target_os = "windows")] +#[inline] +fn windows_portable_service_ipc_allows_logon_helper_executable( + _peer_exe: &Path, + postfix: &str, +) -> bool { + if postfix != "_portable_service" { + return false; + } + #[cfg(feature = "flutter")] + { + false + } + #[cfg(not(feature = "flutter"))] + { + let Some((_, expected)) = crate::platform::windows::portable_service_logon_helper_paths() + else { + return false; + }; + let Ok(expected) = fs::canonicalize(expected) else { + return false; + }; + let Ok(current_exe) = current_exe_canonical_path() else { + return false; + }; + portable_service_helper_is_trusted(_peer_exe, &expected, ¤t_exe) + } +} + +#[cfg(windows)] +#[inline] +pub(crate) fn is_allowed_windows_session_scoped_peer( + client_is_system: bool, + client_session_id: Option, + expected_session_id: Option, +) -> bool { + client_is_system + || matches!( + (client_session_id, expected_session_id), + (Some(client), Some(expected)) if client == expected + ) +} + +#[cfg(windows)] +#[inline] +fn is_allowed_windows_portable_service_peer( + client_is_system: Option, + _client_session_id: Option, + _expected_session_id: Option, +) -> bool { + // Portable-service listener DACL includes SYSTEM and current-process SID. + // In the portable-service path, current process is expected to run as SYSTEM, + // and the higher-layer peer policy stays SYSTEM-only. + matches!(client_is_system, Some(true)) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[inline] +pub(crate) fn is_allowed_service_peer_uid(peer_uid: u32, active_uid: Option) -> bool { + // Root is allowed at the UID gate because the service side may run as root. + // Callers still enforce executable matching before accepting service-scoped peers. + peer_uid == 0 || active_uid.is_some_and(|uid| uid == peer_uid) +} + +#[cfg(target_os = "macos")] +#[inline] +fn console_owner_uid() -> Option { + fs::metadata("/dev/console") + .ok() + .map(|metadata| metadata.uid()) +} + +#[cfg(target_os = "macos")] +#[inline] +fn active_uid_strict() -> Option { + // Prefer the filesystem metadata over parsing external command output. + console_owner_uid() +} + +#[cfg(target_os = "linux")] +#[inline] +fn active_uid_strict() -> Option { + let reported_uid_raw = crate::platform::linux::get_active_userid(); + let trimmed = reported_uid_raw.trim(); + if let Ok(uid) = trimmed.parse::() { + return Some(uid); + } + if trimmed.is_empty() { + log::debug!("Failed to resolve active user uid on linux: active uid is empty"); + } else { + log::warn!("Failed to parse active user uid on linux: '{}'", trimmed); + } + None +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +pub(crate) fn active_uid() -> Option { + active_uid_strict() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option { + #[cfg(target_os = "linux")] + { + return peer_cred_from_fd(fd).map(|cred| cred.uid as u32); + } + #[cfg(target_os = "macos")] + { + let mut uid = 0; + let mut gid = 0; + if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } == 0 { + Some(uid as u32) + } else { + None + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn peer_pid_from_fd(fd: RawFd) -> Option { + #[cfg(target_os = "linux")] + { + return peer_cred_from_fd(fd).and_then(|cred| (cred.pid > 0).then_some(cred.pid as u32)); + } + #[cfg(target_os = "macos")] + { + let mut pid = 0; + let mut len = std::mem::size_of::() as _; + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + &mut pid as *mut _ as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 && pid > 0 { + Some(pid as _) + } else { + None + } + } +} + +#[cfg(target_os = "linux")] +#[inline] +fn peer_cred_from_fd(fd: RawFd) -> Option { + let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as _; + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut _ as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 { + Some(cred) + } else { + None + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +fn current_exe_canonical_path() -> ResultType { + let current = std::env::current_exe() + .map_err(|err| anyhow::anyhow!("Failed to resolve current executable path: {}", err))?; + fs::canonicalize(¤t).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize current executable path '{}': {}", + current.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "linux")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + let proc_exe = PathBuf::from(format!("/proc/{peer_pid}/exe")); + let peer_exe = fs::read_link(&proc_exe).map_err(|err| { + anyhow::anyhow!( + "Failed to read peer executable link '{}': {}", + proc_exe.display(), + err + ) + })?; + fs::canonicalize(&peer_exe).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + peer_exe.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "macos")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + const PROC_PIDPATH_BUF_SIZE: usize = libc::PROC_PIDPATHINFO_MAXSIZE as _; + let mut buffer = vec![0u8; PROC_PIDPATH_BUF_SIZE]; + let length = unsafe { + libc::proc_pidpath( + peer_pid as _, + buffer.as_mut_ptr() as _, + PROC_PIDPATH_BUF_SIZE as _, + ) + }; + if length <= 0 { + bail!("Failed to query peer process path from pid {}", peer_pid); + } + buffer.truncate(length as _); + let path = PathBuf::from(String::from_utf8_lossy(&buffer).to_string()); + fs::canonicalize(&path).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + path.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "windows")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + let path = crate::platform::windows::get_process_executable_path(peer_pid)?; + fs::canonicalize(&path).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + path.display(), + err + ) + .into() + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +pub(crate) fn executable_paths_match(left: &Path, right: &Path) -> bool { + #[cfg(target_os = "windows")] + { + // Callers pass paths resolved through fs::canonicalize() first, so NT + // namespace paths and 8.3 short names are expected to be resolved before + // this check. Keep this normalization limited to remaining Win32 spelling + // differences. + fn normalize(path: &Path) -> String { + let mut normalized = path.to_string_lossy().replace('/', "\\"); + if let Some(stripped) = normalized.strip_prefix(r"\\?\") { + normalized = stripped.to_owned(); + } + normalized.to_ascii_lowercase() + } + return normalize(left) == normalize(right); + } + #[cfg(target_os = "macos")] + { + return paths_refer_to_same_file(left, right); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + left == right + } +} + +#[cfg(target_os = "macos")] +#[inline] +fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) else { + return false; + }; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(target_os = "macos")] +#[inline] +fn os_str_eq_ignore_ascii_case( + left: Option<&std::ffi::OsStr>, + right: Option<&std::ffi::OsStr>, +) -> bool { + let (Some(left), Some(right)) = (left, right) else { + return false; + }; + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +#[cfg(all(windows, not(feature = "flutter")))] +#[inline] +fn file_sha256(path: &Path) -> ResultType<[u8; 32]> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8 * 1024]; + loop { + let read_bytes = file.read(&mut buffer)?; + if read_bytes == 0 { + break; + } + hasher.update(&buffer[..read_bytes]); + } + Ok(hasher.finalize().into()) +} + +#[cfg(all(windows, not(feature = "flutter")))] +#[inline] +fn portable_service_helper_is_trusted( + peer_exe: &Path, + expected_exe: &Path, + current_exe: &Path, +) -> bool { + if !executable_paths_match(peer_exe, expected_exe) { + return false; + } + let peer_hash = match file_sha256(peer_exe) { + Ok(hash) => hash, + Err(err) => { + log::warn!( + "Failed to hash peer portable helper executable '{}': {}", + peer_exe.display(), + err + ); + return false; + } + }; + let current_hash = match file_sha256(current_exe) { + Ok(hash) => hash, + Err(err) => { + log::warn!( + "Failed to hash current executable '{}' for portable helper trust check: {}", + current_exe.display(), + err + ); + return false; + } + }; + peer_hash == current_hash +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +fn ensure_peer_executable_matches_current_by_pid(peer_pid: u32, postfix: &str) -> ResultType<()> { + let peer_exe = peer_exe_canonical_path_by_pid(peer_pid)?; + let current_exe = current_exe_canonical_path()?; + if executable_paths_match(&peer_exe, ¤t_exe) { + return Ok(()); + } + #[cfg(target_os = "macos")] + if macos_service_ipc_allows_gui_and_service_binaries(&peer_exe, ¤t_exe, postfix) { + return Ok(()); + } + #[cfg(target_os = "windows")] + if windows_portable_service_ipc_allows_logon_helper_executable(&peer_exe, postfix) { + return Ok(()); + } + bail!( + "Peer executable path mismatch on ipc channel '{}': peer_pid={}, peer_exe='{}', current_exe='{}'", + postfix, + peer_pid, + peer_exe.display(), + current_exe.display() + ); +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +pub(crate) fn ensure_peer_executable_matches_current_by_pid_opt( + peer_pid: Option, + postfix: &str, +) -> ResultType<()> { + let peer_pid = peer_pid.ok_or_else(|| { + anyhow::anyhow!("Failed to resolve peer pid on ipc channel '{}'", postfix) + })?; + ensure_peer_executable_matches_current_by_pid(peer_pid, postfix) +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn ensure_peer_executable_matches_current_by_fd( + fd: RawFd, + postfix: &str, +) -> ResultType<()> { + let peer_pid = peer_pid_from_fd(fd).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve peer pid on ipc channel '{}'", postfix) + })?; + ensure_peer_executable_matches_current_by_pid(peer_pid, postfix) +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +const UNAUTHORIZED_IPC_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[derive(Default)] +struct UnauthorizedIpcLogThrottle { + last_log_at: Option, + suppressed: u64, +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +impl UnauthorizedIpcLogThrottle { + #[inline] + fn on_reject(&mut self, now: std::time::Instant) -> Option { + if let Some(last) = self.last_log_at { + if now.saturating_duration_since(last) < UNAUTHORIZED_IPC_LOG_INTERVAL { + self.suppressed += 1; + return None; + } + } + self.last_log_at = Some(now); + Some(std::mem::take(&mut self.suppressed)) + } +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[inline] +fn throttled_unauthorized_ipc_log( + throttle_cell: &OnceLock>, + emit: impl FnOnce(u64), +) { + let throttle = throttle_cell.get_or_init(|| Mutex::new(UnauthorizedIpcLogThrottle::default())); + let should_log = match throttle.lock() { + Ok(mut throttle) => throttle.on_reject(std::time::Instant::now()), + Err(_) => Some(0), + }; + if let Some(suppressed) = should_log { + emit(suppressed); + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn log_rejected_service_connection(postfix: &str, peer_uid: Option, active_uid: Option) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)", + postfix, + peer_uid, + active_uid, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}", + postfix, + peer_uid, + active_uid + ); + } + }); +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn log_rejected_uinput_connection( + postfix: &str, + peer_uid: Option, + active_uid: Option, +) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)", + postfix, + peer_uid, + active_uid, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}", + postfix, + peer_uid, + active_uid + ); + } + }); +} + +#[cfg(windows)] +#[inline] +pub(crate) fn log_rejected_windows_ipc_connection( + postfix: &str, + peer_pid: Option, + peer_session_id: Option, + expected_session_id: Option, + peer_is_system: Option, +) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?} (suppressed {} similar events)", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system + ); + } + }); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postfix: &str) -> bool { + let peer_pid = stream.peer_pid(); + let (authorized, peer_uid, active_uid) = stream.service_authorization_status(); + if !authorized { + log_rejected_service_connection(postfix, peer_uid, active_uid); + return false; + } + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + return false; + } + true +} + +#[cfg(windows)] +pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { + let (authorized, peer_pid, peer_session_id, server_session_id, peer_is_system) = + stream.server_authorization_status(); + if !authorized { + log_rejected_windows_ipc_connection( + postfix, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + ); + return false; + } + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Rejected unauthorized connection on ipc channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + return false; + } + true +} + +#[cfg(windows)] +pub(crate) fn authorize_windows_portable_service_ipc_connection( + stream: &Connection, + postfix: &str, +) -> bool { + // Portable service IPC policy: + // - only SYSTEM peers are authorized by is_allowed_windows_portable_service_peer() + // - expected_session_id is still collected for diagnostics and identity checks + // - final privilege boundary is enforced by named-pipe ACL + one-time token handshake + // - when peer identity is unavailable on some hosts, executable verification remains + // best-effort telemetry (not fail-closed) to avoid breaking valid SYSTEM bootstrap + // flows that cannot be fully introspected + let expected_session_id = crate::platform::windows::get_current_process_session_id(); + let (authorized, peer_pid, peer_session_id, peer_is_system) = + stream.portable_service_authorization_status_for_session(expected_session_id); + if !authorized { + // Session lookup may succeed while SYSTEM identity lookup fails, so only the + // SYSTEM identity result determines whether peer identity is unavailable here. + // Don't use `peer_pid.is_some() && peer_session_id.is_none() && peer_is_system.is_none();` here. + let identity_unavailable = peer_pid.is_some() && peer_is_system.is_none(); + if identity_unavailable { + // In portable-service startup, resolving SYSTEM peer identity may fail on some hosts. + // `ProcessIdToSessionId` can still succeed while `OpenProcessToken(TOKEN_QUERY)` is + // denied by the peer token DACL or missing privileges. Treat that partial identity + // failure as unavailable and defer final authorization to pipe ACL + token handshake. + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Portable service ipc peer identity unavailable and executable verification failed; continue with ACL+token-gated flow: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + } else { + log::warn!( + "Portable service ipc peer identity unavailable; executable verification matched, continue with ACL+token-gated flow: postfix={}, peer_pid={:?}, expected_session_id={:?}", + postfix, + peer_pid, + expected_session_id + ); + } + return true; + } + log::warn!( + "Rejected unauthorized connection on portable service ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system + ); + return false; + } + true +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl ConnectionTmpl +where + T: AsyncRead + AsyncWrite + std::marker::Unpin + std::os::unix::io::AsRawFd, +{ + pub(super) fn peer_uid(&self) -> Option { + peer_uid_from_fd(self.inner.get_ref().as_raw_fd()) + } + + fn service_authorization_status(&self) -> (bool, Option, Option) { + let peer_uid = self.peer_uid(); + // On Linux, `_service` can use the cached active UID from the service loop for + // stable config sync. Uinput does a fresh active-UID lookup in its own authorizer. + let active_uid = active_uid(); + let authorized = peer_uid.is_some_and(|uid| is_allowed_service_peer_uid(uid, active_uid)); + (authorized, peer_uid, active_uid) + } + + pub(super) fn peer_pid(&self) -> Option { + peer_pid_from_fd(self.inner.get_ref().as_raw_fd()) + } +} + +#[cfg(windows)] +impl ConnectionTmpl { + fn peer_pid(&self) -> Option { + let pipe_handle = self.inner.get_ref().as_raw_handle(); + if pipe_handle.is_null() { + return None; + } + let mut pid = 0u32; + let ok = unsafe { GetNamedPipeClientProcessId(HANDLE(pipe_handle), &mut pid as *mut u32) } + .is_ok(); + if ok && pid != 0 { + Some(pid) + } else { + None + } + } + + fn server_authorization_status( + &self, + ) -> (bool, Option, Option, Option, Option) { + let peer_pid = self.peer_pid(); + let server_session_id = crate::platform::windows::get_current_process_session_id(); + let peer_session_id = + peer_pid.and_then(crate::platform::windows::get_session_id_of_process); + let peer_is_system_result = + peer_pid.map(crate::platform::windows::is_process_running_as_system); + let peer_is_system = peer_is_system_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + if server_session_id.is_none() && !peer_is_system.unwrap_or(false) { + // When the server session id cannot be determined, the session-id allow-path is + // disabled and only SYSTEM peers can be authorized. + log::debug!( + "IPC authorization: server session id unavailable; rejecting non-SYSTEM peer, peer_pid={:?}, peer_session_id={:?}", + peer_pid, + peer_session_id + ); + } + let authorized = is_allowed_windows_session_scoped_peer( + peer_is_system.unwrap_or(false), + peer_session_id, + server_session_id, + ); + if !authorized { + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is SYSTEM, pid={}, err={}", + pid, + err + ); + } + } + ( + authorized, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + ) + } + + pub(crate) fn service_authorization_status_for_session( + &self, + expected_active_session_id: Option, + ) -> (bool, Option, Option, Option) { + let peer_pid = self.peer_pid(); + let peer_session_id = + peer_pid.and_then(crate::platform::windows::get_session_id_of_process); + let peer_is_system_result = + peer_pid.map(crate::platform::windows::is_process_running_as_system); + let peer_is_system = peer_is_system_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + let authorized = is_allowed_windows_session_scoped_peer( + peer_is_system.unwrap_or(false), + peer_session_id, + expected_active_session_id, + ); + if !authorized { + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is SYSTEM, pid={}, err={}", + pid, + err + ); + } + } + (authorized, peer_pid, peer_session_id, peer_is_system) + } + + pub(crate) fn portable_service_authorization_status_for_session( + &self, + expected_active_session_id: Option, + ) -> (bool, Option, Option, Option) { + // Portable-service policy: + // only SYSTEM peers are allowed. + let (_service_authorized, peer_pid, peer_session_id, peer_is_system) = + self.service_authorization_status_for_session(expected_active_session_id); + ( + is_allowed_windows_portable_service_peer( + peer_is_system, + peer_session_id, + expected_active_session_id, + ), + peer_pid, + peer_session_id, + peer_is_system, + ) + } +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn test_service_peer_uid_policy() { + assert!(super::is_allowed_service_peer_uid(0, None)); + assert!(super::is_allowed_service_peer_uid(501, Some(501))); + assert!(!super::is_allowed_service_peer_uid(502, Some(501))); + assert!(!super::is_allowed_service_peer_uid(501, None)); + } + + #[test] + #[cfg(windows)] + fn test_windows_server_peer_policy() { + assert!(super::is_allowed_windows_session_scoped_peer( + true, None, None + )); + assert!(super::is_allowed_windows_session_scoped_peer( + false, + Some(1), + Some(1) + )); + assert!(!super::is_allowed_windows_session_scoped_peer( + false, + Some(1), + Some(2) + )); + assert!(!super::is_allowed_windows_session_scoped_peer( + false, + None, + Some(1) + )); + } + + #[test] + #[cfg(windows)] + fn test_windows_portable_service_peer_policy() { + assert!(super::is_allowed_windows_portable_service_peer( + Some(true), + None, + None + )); + assert!(!super::is_allowed_windows_portable_service_peer( + Some(false), + Some(1), + Some(1) + )); + assert!(!super::is_allowed_windows_portable_service_peer( + Some(false), + Some(1), + Some(2) + )); + assert!(!super::is_allowed_windows_portable_service_peer( + None, + Some(1), + Some(1) + )); + } + + #[test] + #[cfg(windows)] + fn test_should_allow_everyone_create_on_windows_policy() { + assert!(super::should_allow_everyone_create_on_windows("")); + assert!(super::should_allow_everyone_create_on_windows("_service")); + assert!(!super::should_allow_everyone_create_on_windows( + "_portable_service" + )); + } + + #[test] + #[cfg(windows)] + fn test_executable_paths_match_windows_normalization() { + let left = std::path::PathBuf::from(r"\\?\C:\Program Files\RustDesk\RustDesk.exe"); + let right = std::path::PathBuf::from(r"c:\program files\rustdesk\rustdesk.exe"); + assert!(super::executable_paths_match(&left, &right)); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_os_str_eq_ignore_ascii_case_for_process_names() { + assert!(super::os_str_eq_ignore_ascii_case( + Some(std::ffi::OsStr::new("RustDesk")), + Some(std::ffi::OsStr::new("rustdesk")) + )); + assert!(!super::os_str_eq_ignore_ascii_case( + Some(std::ffi::OsStr::new("RustDesk")), + Some(std::ffi::OsStr::new("service")) + )); + } + + #[cfg(all(windows, not(feature = "flutter")))] + struct TempDirGuard(std::path::PathBuf); + + #[cfg(all(windows, not(feature = "flutter")))] + impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + #[cfg(all(windows, not(feature = "flutter")))] + fn test_portable_service_helper_trust_requires_content_match() { + let unique = format!( + "rustdesk-portable-helper-trust-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + let _cleanup = TempDirGuard(base.clone()); + + let current_exe = base.join("current.exe"); + let helper_exe = base.join("helper.exe"); + std::fs::write(¤t_exe, b"trusted-binary").unwrap(); + std::fs::write(&helper_exe, b"tampered-binary").unwrap(); + + assert!( + !super::portable_service_helper_is_trusted(&helper_exe, &helper_exe, ¤t_exe), + "helper trust check must reject path-match-only binaries with mismatched content" + ); + } + + #[test] + #[cfg(all(windows, not(feature = "flutter")))] + fn test_portable_service_helper_trust_accepts_matching_content() { + let unique = format!( + "rustdesk-portable-helper-trust-match-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + let _cleanup = TempDirGuard(base.clone()); + + let current_exe = base.join("current.exe"); + let helper_exe = base.join("helper.exe"); + std::fs::write(¤t_exe, b"trusted-binary").unwrap(); + std::fs::write(&helper_exe, b"trusted-binary").unwrap(); + + assert!(super::portable_service_helper_is_trusted( + &helper_exe, + &helper_exe, + ¤t_exe + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_console_owner_uid_matches_get_active_userid() { + let console_uid = + super::console_owner_uid().expect("/dev/console must have a resolvable uid"); + let raw_uid = crate::platform::macos::get_active_userid(); + let parsed_uid: u32 = raw_uid + .trim() + .parse() + .unwrap_or_else(|_| panic!("failed to parse get_active_userid() output: '{raw_uid}'")); + assert_eq!(parsed_uid, console_uid); + } +} diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs new file mode 100644 index 000000000..e0157f3a9 --- /dev/null +++ b/src/ipc/fs.rs @@ -0,0 +1,951 @@ +#[cfg(target_os = "linux")] +use super::ipc_auth::active_uid; +use crate::ipc::{connect, Data}; +use hbb_common::{config, log, ResultType}; +use std::{ + ffi::CString, + io::{Error, ErrorKind}, + os::unix::ffi::OsStrExt, + path::Path, +}; + +struct FdGuard(i32); +impl Drop for FdGuard { + fn drop(&mut self) { + unsafe { + hbb_common::libc::close(self.0); + } + } +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn terminal_count_candidate_uids(effective_uid: u32) -> Vec { + if effective_uid != 0 { + return vec![effective_uid]; + } + let mut candidates = Vec::with_capacity(2); + if let Some(uid) = active_uid().filter(|uid| *uid != 0) { + candidates.push(uid); + } + candidates.push(0); + candidates +} + +#[inline] +fn expected_ipc_parent_mode(postfix: &str) -> u32 { + if config::is_service_ipc_postfix(postfix) { + 0o0711 + } else { + 0o0700 + } +} + +fn open_ipc_parent_dir_fd(parent_c: &CString) -> std::io::Result { + let fd = unsafe { + hbb_common::libc::open( + parent_c.as_ptr(), + hbb_common::libc::O_RDONLY + | hbb_common::libc::O_DIRECTORY + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(fd) + } +} + +// Remove one preexisting IPC artifact via an already-opened parent directory FD. +// +// Security intent: +// - Bind cleanup to the exact parent inode that passed O_NOFOLLOW + fstat checks. +// - Avoid path-based TOCTOU during scrub (e.g., parent path rename/swap race). +// +// Flow: +// 1) fstatat(..., AT_SYMLINK_NOFOLLOW) to inspect the target entry under parent_fd. +// 2) Decide file vs directory from st_mode. +// 3) unlinkat relative to parent_fd (AT_REMOVEDIR for directories). +// +// Error policy: +// - NotFound is treated as benign (already removed / raced away). +// - Other errors are surfaced explicitly. +fn remove_parent_entry_via_fd( + parent_fd: i32, + parent_dir: &Path, + entry_name: &str, +) -> ResultType<()> { + if entry_name.contains('/') { + return Err(Error::new( + ErrorKind::InvalidInput, + format!( + "invalid ipc parent entry name (contains '/'): parent={}, entry={}", + parent_dir.display(), + entry_name + ), + ) + .into()); + } + let entry_c = CString::new(entry_name.as_bytes().to_vec()).map_err(|err| { + Error::new( + ErrorKind::InvalidInput, + format!( + "invalid ipc parent entry name: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + })?; + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + let stat_rc = unsafe { + hbb_common::libc::fstatat( + parent_fd, + entry_c.as_ptr(), + &mut stat, + hbb_common::libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_rc != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + err.kind(), + format!( + "failed to stat preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + .into()); + } + + let is_dir = (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + == hbb_common::libc::S_IFDIR; + let unlink_flags = if is_dir { + hbb_common::libc::AT_REMOVEDIR + } else { + 0 + }; + let unlink_rc = + unsafe { hbb_common::libc::unlinkat(parent_fd, entry_c.as_ptr(), unlink_flags) }; + if unlink_rc != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + err.kind(), + format!( + "failed to remove preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + .into()); + } + Ok(()) +} + +fn scrub_preexisting_ipc_parent_entries( + parent_fd: i32, + parent_dir: &Path, + postfix: &str, +) -> ResultType<()> { + let ipc_basename = format!("ipc{}", postfix); + remove_parent_entry_via_fd(parent_fd, parent_dir, &ipc_basename)?; + remove_parent_entry_via_fd(parent_fd, parent_dir, &format!("{}.pid", ipc_basename))?; + Ok(()) +} + +fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { + let path = config::Config::ipc_path(postfix); + let parent_dir = Path::new(&path) + .parent() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; + let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; + let fd = match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(open_err) => { + if open_err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + open_err.kind(), + format!( + "failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + open_err + ), + ) + .into()); + } + }; + let _fd_guard = FdGuard(fd); + remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix)) +} + +// Purpose: +// - Harden the IPC parent directory before creating/listening socket files. +// - Prevent symlink/path-race abuse and reject unsafe owner/mode. +// +// Approach: +// - Open parent dir with O_NOFOLLOW/O_DIRECTORY and operate on that fd. +// - Validate inode type/owner/mode via fstat. +// - For protected service postfix, optionally adopt owner (root only), then scrub stale +// rustdesk IPC artifacts when directory trust boundary changed. +// +// Main steps: +// 1) Resolve parent path and open/create directory securely. +// 2) Verify directory inode type and owner uid. +// 3) Enforce expected mode via fchmod on opened fd. +// 4) Scrub stale IPC artifacts when owner/mode was unsafe before hardening. +// +// References: +// - open(2): O_NOFOLLOW/O_DIRECTORY/O_CLOEXEC +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): verify file type/metadata on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - fchown(2): adopt ownership when running as root +// https://man7.org/linux/man-pages/man2/chown.2.html +// - fchmod(2): enforce exact mode on opened fd +// https://man7.org/linux/man-pages/man2/fchmod.2.html +pub(crate) fn ensure_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType { + let parent_dir = Path::new(path) + .parent() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; + // Harden against common TOCTOU by opening the parent directory with O_NOFOLLOW (so the parent + // itself cannot be a symlink) and then operating on its FD (fstat/fchown/fchmod). This ensures + // we mutate the inode we opened, though it does not protect against symlinks in ancestor path + // components. + let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; + let fd = match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(open_err) => { + // If the directory doesn't exist yet, create it with the expected mode. The parent + // dir is intended to be a single-level /tmp path, so mkdir is sufficient here. + if open_err.raw_os_error() == Some(hbb_common::libc::ENOENT) { + let expected_mode = expected_ipc_parent_mode(postfix); + let rc = unsafe { + hbb_common::libc::mkdir( + parent_c.as_ptr(), + expected_mode as hbb_common::libc::mode_t, + ) + }; + if rc != 0 { + let mkdir_err = std::io::Error::last_os_error(); + // Handle a race where another process created the directory first. + if mkdir_err.raw_os_error() != Some(hbb_common::libc::EEXIST) { + return Err(Error::new( + mkdir_err.kind(), + format!( + "failed to mkdir ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + mkdir_err + ), + ) + .into()); + } + } + match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(err) => { + return Err(Error::new( + err.kind(), + format!( + "failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + err + ), + ) + .into()); + } + } + } else { + return Err(Error::new( + open_err.kind(), + format!( + "failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + open_err + ), + ) + .into()); + } + } + }; + let _fd_guard = FdGuard(fd); + + let mut st: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut st as *mut _) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to stat ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + os_err + ), + ) + .into()); + } + let mode = st.st_mode as u32; + let is_dir = (mode & (hbb_common::libc::S_IFMT as u32)) == (hbb_common::libc::S_IFDIR as u32); + if !is_dir { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "ipc parent is not directory: postfix={}, parent={}", + postfix, + parent_dir.display() + ), + ) + .into()); + } + + let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let mut owner_uid = st.st_uid as u32; + let mut adopted_foreign_service_parent = false; + // Service-scoped IPC may be created by different privilege contexts historically. + // If running as root on protected service postfix, try adopting ownership first. + if owner_uid != expected_uid && expected_uid == 0 && config::is_service_ipc_postfix(postfix) { + let rc = unsafe { + hbb_common::libc::fchown( + fd, + expected_uid as hbb_common::libc::uid_t, + hbb_common::libc::gid_t::MAX, + ) + }; + if rc == 0 { + let mut st2: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut st2 as *mut _) } == 0 { + owner_uid = st2.st_uid as u32; + st = st2; + adopted_foreign_service_parent = true; + } + } else { + // Keep behavior unchanged; capture errno to ease diagnosing why chown failed. + let err = std::io::Error::last_os_error(); + log::warn!( + "Failed to chown ipc parent dir, parent={}, postfix={}, expected_uid={}, rc={}, err={:?}", + parent_dir.display(), + postfix, + expected_uid, + rc, + err + ); + } + } + if owner_uid != expected_uid { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "unsafe ipc parent owner, postfix={}, expected uid {expected_uid}, got {owner_uid}: {}", + postfix, + parent_dir.display() + ), + ) + .into()); + } + + let expected_mode = expected_ipc_parent_mode(postfix); + // Include special bits (setuid/setgid/sticky) to ensure the directory is hardened to the exact + // expected mode. + let current_mode = (st.st_mode as u32) & 0o7777; + let repaired_parent_mode = current_mode != expected_mode; + let had_untrusted_parent_mode = (current_mode & 0o022) != 0; + if repaired_parent_mode { + // Use fchmod on the opened fd to avoid path-race between check and chmod. + if unsafe { hbb_common::libc::fchmod(fd, expected_mode as hbb_common::libc::mode_t) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to chmod ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + os_err + ), + ) + .into()); + } + } + let should_scrub = + repaired_parent_mode || adopted_foreign_service_parent || had_untrusted_parent_mode; + Ok(should_scrub) +} + +pub(crate) fn scrub_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType<()> { + let parent_dir = Path::new(path) + .parent() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; + let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; + let fd = open_ipc_parent_dir_fd(&parent_c).map_err(|err| { + Error::new( + err.kind(), + format!( + "failed to open ipc parent dir for scrub (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + err + ), + ) + })?; + let _fd_guard = FdGuard(fd); + scrub_preexisting_ipc_parent_entries(fd, parent_dir, postfix) +} + +#[inline] +pub(crate) fn get_pid_file(postfix: &str) -> String { + let path = config::Config::ipc_path(postfix); + format!("{}.pid", path) +} + +// Purpose: +// - Write current process pid to pid file without following attacker-controlled symlinks. +// - Ensure the pid file is a regular file owned by the opened inode path. +// +// Approach: +// - Use libc open/fstat/write syscalls (FFI) so flags and inode validation are explicit. +// - Open file with O_NOFOLLOW/O_CLOEXEC and verify S_IFREG with fstat before write. +// - Keep unsafe scopes minimal and check syscall return values immediately. +// +// Main steps: +// 1) Secure-open pid file (without truncation). +// 2) Validate opened inode is a regular file owned by current euid. +// 3) Enforce pid file mode to 0600 and truncate via ftruncate after validation. +// 4) Write process id bytes through fd. +// +// Why not plain std::fs::write? +// - std::fs helpers cannot enforce this exact open-time hardening sequence +// (especially "open with O_NOFOLLOW, then fstat the same opened inode"). +// +// References: +// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): verify file type on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - fchmod(2): enforce secure mode on reused pid file +// https://man7.org/linux/man-pages/man2/fchmod.2.html +// - ftruncate(2): truncate after validation +// https://man7.org/linux/man-pages/man2/ftruncate.2.html +// - write(2): write bytes via fd +// https://man7.org/linux/man-pages/man2/write.2.html +fn write_pid_file(path: &Path) -> ResultType<()> { + let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).map_err(|err| { + Error::new( + ErrorKind::InvalidInput, + format!("invalid pid file path '{}': {}", path.display(), err), + ) + })?; + let flags = hbb_common::libc::O_WRONLY + | hbb_common::libc::O_CREAT + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW + | hbb_common::libc::O_NONBLOCK; + let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags, 0o0600) }; + if fd < 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to open pid file with no-follow '{}': {}", + path.display(), + os_err + ), + ) + .into()); + } + let _fd_guard = FdGuard(fd); + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to stat pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + != (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t) + { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!("pid file path is not a regular file: '{}'", path.display()), + ) + .into()); + } + let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + if stat.st_uid as u32 != expected_uid { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "pid file owner mismatch: expected uid {}, got {} for '{}'", + expected_uid, + stat.st_uid, + path.display() + ), + ) + .into()); + } + if unsafe { hbb_common::libc::fchmod(fd, 0o600) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to chmod pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if unsafe { hbb_common::libc::ftruncate(fd, 0) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to truncate pid file '{}': {}", + path.display(), + os_err + ), + ) + .into()); + } + + let bytes = std::process::id().to_string(); + let buf = bytes.as_bytes(); + // `write(2)` is allowed to return a short write even for regular files. + // PID content is tiny and usually written in one shot, but we still loop + // until all bytes are persisted so this path is semantically correct. + let mut written = 0usize; + while written < buf.len() { + let rc = unsafe { + hbb_common::libc::write( + fd, + buf[written..].as_ptr() as *const hbb_common::libc::c_void, + buf.len() - written, + ) + }; + if rc < 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to write pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if rc == 0 { + return Err(Error::new( + ErrorKind::WriteZero, + format!( + "failed to write pid file '{}': write returned 0 bytes", + path.display() + ), + ) + .into()); + } + written += rc as usize; + } + Ok(()) +} + +#[inline] +pub(crate) fn write_pid(postfix: &str) { + let path = std::path::PathBuf::from(get_pid_file(postfix)); + if let Err(err) = write_pid_file(&path) { + log::warn!( + "Failed to write pid file for postfix '{}', path='{}', err={}", + postfix, + path.display(), + err + ); + } +} + +// Purpose: +// - Read pid file safely and avoid trusting symlink/non-regular files. +// +// Approach: +// - Use libc open/fstat/read syscalls (FFI) to control flags and inode checks. +// - Open path with O_NOFOLLOW, validate opened fd via fstat, then read and parse. +// - Keep unsafe scopes minimal and check syscall return values immediately. +// +// Main steps: +// 1) Secure-open pid file read-only. +// 2) Ensure fd points to regular file. +// 3) Read bytes and parse usize pid. +// +// References: +// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): validate S_IFREG on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - read(2): read bytes via fd +// https://man7.org/linux/man-pages/man2/read.2.html +#[inline] +fn read_pid_file_secure(path: &Path) -> Option { + let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).ok()?; + let flags = hbb_common::libc::O_RDONLY + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW + | hbb_common::libc::O_NONBLOCK; + let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags) }; + if fd < 0 { + return None; + } + let _fd_guard = FdGuard(fd); + + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 { + return None; + } + if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + != (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t) + { + return None; + } + + let mut buffer = [0u8; 64]; + let read_len = unsafe { + hbb_common::libc::read( + fd, + buffer.as_mut_ptr() as *mut hbb_common::libc::c_void, + buffer.len(), + ) + }; + if read_len <= 0 { + return None; + } + let content = String::from_utf8_lossy(&buffer[..read_len as usize]).to_string(); + content.trim().parse::().ok() +} + +#[inline] +async fn probe_existing_listener(postfix: &str) -> bool { + let Ok(mut stream) = connect(1000, postfix).await else { + return false; + }; + if postfix != crate::POSTFIX_SERVICE { + return true; + } + if stream.send(&Data::SyncConfig(None)).await.is_err() { + return false; + } + matches!( + stream.next_timeout(1000).await, + Ok(Some(Data::SyncConfig(Some(_)))) + ) +} + +pub(crate) async fn check_pid(postfix: &str) -> bool { + let pid_file = std::path::PathBuf::from(get_pid_file(postfix)); + if let Some(pid) = read_pid_file_secure(&pid_file) { + if pid > 0 { + let mut sys = hbb_common::sysinfo::System::new(); + sys.refresh_processes(); + if let Some(p) = sys.process(pid.into()) { + if let Some(current) = sys.process((std::process::id() as usize).into()) { + if current.name() == p.name() && probe_existing_listener(postfix).await { + return true; + } + } + } + } + } + if probe_existing_listener(postfix).await { + return true; + } + // if not remove old ipc file, the new ipc creation will fail + // if we remove a ipc file, but the old ipc process is still running, + // new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive + if let Err(err) = remove_ipc_socket_via_secure_parent_fd(postfix) { + log::debug!( + "Failed to remove stale ipc socket via secure parent fd: postfix={}, err={}", + postfix, + err + ); + } + false +} + +#[inline] +pub(crate) fn should_scrub_parent_entries_after_check_pid( + should_scrub_parent_entries: bool, + existing_listener_alive: bool, +) -> bool { + should_scrub_parent_entries && !existing_listener_alive +} + +#[cfg(test)] +mod tests { + #[test] + fn test_write_pid_file_rejects_symlink() { + use std::os::unix::fs::symlink; + + let unique = format!( + "rustdesk-ipc-pid-file-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let target = base.join("target_pid"); + std::fs::write(&target, b"origin").unwrap(); + let link = base.join("pid_link"); + symlink(&target, &link).unwrap(); + + let res = super::write_pid_file(&link); + assert!(res.is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "origin"); + + std::fs::remove_file(&link).ok(); + std::fs::remove_file(&target).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_rejects_symlink_parent() { + use std::os::unix::fs::symlink; + + let unique = format!( + "rustdesk-ipc-secure-dir-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + let real_dir = base.join("real"); + let link_dir = base.join("link"); + std::fs::create_dir_all(&real_dir).unwrap(); + symlink(&real_dir, &link_dir).unwrap(); + let ipc_path = link_dir.join("ipc_service"); + let res = + super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), "_service"); + assert!(res.is_err()); + std::fs::remove_file(&link_dir).ok(); + std::fs::remove_dir_all(&real_dir).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_creates_parent_with_expected_mode() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-secure-dir-create-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + // Intentionally choose a parent that does not exist to exercise the ENOENT -> mkdir branch. + let parent_dir = base.join("parent"); + assert!(!parent_dir.exists()); + let ipc_path = parent_dir.join("ipc"); + + let res = super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), ""); + // Restrictive umask can make mkdir create a stricter initial mode. In that case + // ensure_secure_ipc_parent_dir repairs it with fchmod and may request a scrub. + res.unwrap(); + + let md = std::fs::metadata(&parent_dir).unwrap(); + assert!(md.is_dir()); + let mode = md.permissions().mode() & 0o777; + assert_eq!(mode, 0o0700); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_scrub_preexisting_ipc_parent_entries_only_removes_target_postfix_artifacts() { + use std::os::unix::ffi::OsStrExt; + + let unique = format!( + "rustdesk-ipc-scrub-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let ipc_file = base.join("ipc_service"); + let ipc_pid_file = base.join("ipc_service.pid"); + let ipc_other_postfix_file = base.join("ipc_uinput_1"); + let keep_file = base.join("keep.txt"); + let keep_dir = base.join("keep_dir"); + + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + std::fs::write(&ipc_pid_file, b"1234").unwrap(); + std::fs::write(&ipc_other_postfix_file, b"other-postfix").unwrap(); + std::fs::write(&keep_file, b"keep").unwrap(); + std::fs::create_dir_all(&keep_dir).unwrap(); + + let base_c = std::ffi::CString::new(base.as_os_str().as_bytes().to_vec()).unwrap(); + let base_fd = super::open_ipc_parent_dir_fd(&base_c).unwrap(); + let _base_guard = super::FdGuard(base_fd); + super::scrub_preexisting_ipc_parent_entries(base_fd, &base, "_service").unwrap(); + + assert!(!ipc_file.exists()); + assert!(!ipc_pid_file.exists()); + assert!(ipc_other_postfix_file.exists()); + assert!(keep_file.exists()); + assert!(keep_dir.exists()); + + std::fs::remove_file(&ipc_other_postfix_file).ok(); + std::fs::remove_file(&keep_file).ok(); + std::fs::remove_dir_all(&keep_dir).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_scrub_preexisting_ipc_parent_entries_should_bind_to_opened_inode_not_path() { + use std::os::unix::ffi::OsStrExt; + + let unique = format!( + "rustdesk-ipc-scrub-fd-bind-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let trusted_parent = base.join("trusted_parent"); + let trusted_parent_moved = base.join("trusted_parent_moved"); + let attacker_parent = base.join("attacker_parent"); + std::fs::create_dir_all(&trusted_parent).unwrap(); + std::fs::create_dir_all(&attacker_parent).unwrap(); + + let trusted_ipc_file = trusted_parent.join("ipc_service"); + let attacker_ipc_file = attacker_parent.join("ipc_service"); + std::fs::write(&trusted_ipc_file, b"trusted").unwrap(); + std::fs::write(&attacker_ipc_file, b"attacker").unwrap(); + + let trusted_parent_c = + std::ffi::CString::new(trusted_parent.as_os_str().as_bytes().to_vec()).unwrap(); + let trusted_parent_fd = super::open_ipc_parent_dir_fd(&trusted_parent_c).unwrap(); + let _trusted_parent_guard = super::FdGuard(trusted_parent_fd); + + // Swap the path after the trusted inode has been opened. + std::fs::rename(&trusted_parent, &trusted_parent_moved).unwrap(); + std::fs::rename(&attacker_parent, &trusted_parent).unwrap(); + + super::scrub_preexisting_ipc_parent_entries(trusted_parent_fd, &trusted_parent, "_service") + .unwrap(); + + // Expected secure behavior: scrub should target the inode that was opened before path swap. + assert!( + !trusted_parent_moved.join("ipc_service").exists(), + "trusted inode artifact should be removed even after path swap" + ); + assert!( + trusted_parent.join("ipc_service").exists(), + "path-swapped attacker directory should not be scrubbed" + ); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_keeps_service_artifacts_before_liveness_probe() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-secure-dir-order-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let parent_dir = base.join("service_parent"); + std::fs::create_dir_all(&parent_dir).unwrap(); + // Trigger "had_untrusted_service_parent_mode". + std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o777)).unwrap(); + + let ipc_file = parent_dir.join("ipc_service"); + let ipc_pid_file = parent_dir.join("ipc_service.pid"); + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + std::fs::write(&ipc_pid_file, b"1234").unwrap(); + + let res = + super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "_service"); + assert_eq!(res.unwrap(), true); + + // Parent hardening should run first; artifacts should stay until liveness probe completes. + assert!(ipc_file.exists(), "ipc socket marker should be preserved"); + assert!(ipc_pid_file.exists(), "pid marker should be preserved"); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_marks_non_service_mode_repair_for_scrub() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-nonservice-mode-repair-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let parent_dir = base.join("non_service_parent"); + std::fs::create_dir_all(&parent_dir).unwrap(); + std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let ipc_file = parent_dir.join("ipc"); + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + + let res = super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), ""); + assert_eq!(res.unwrap(), true); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_should_scrub_parent_entries_after_check_pid_only_when_requested_and_not_alive() { + assert!(!super::should_scrub_parent_entries_after_check_pid( + false, false + )); + assert!(!super::should_scrub_parent_entries_after_check_pid( + false, true + )); + assert!(super::should_scrub_parent_entries_after_check_pid( + true, false + )); + assert!(!super::should_scrub_parent_entries_after_check_pid( + true, true + )); + } +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 7157da760..9a4bb37ec 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -29,6 +29,12 @@ use wallpaper; pub const PA_SAMPLE_RATE: u32 = 48000; static mut UNMODIFIED: bool = true; +#[derive(Clone, Debug)] +struct ActiveUserLookupCache { + uid: String, + username: String, +} + const INVALID_TERM_VALUES: [&str; 3] = ["", "unknown", "dumb"]; const SHELL_PROCESSES: [&str; 4] = ["bash", "zsh", "fish", "sh"]; @@ -50,6 +56,8 @@ lazy_static::lazy_static! { } } }; + static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex> = + std::sync::Mutex::new(None); // https://github.com/rustdesk/rustdesk/issues/13705 // Check if `sudo -E` actually preserves environment. // @@ -82,6 +90,27 @@ lazy_static::lazy_static! { }; } +#[inline] +fn update_active_user_lookup_cache(desktop: &Desktop) { + if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() { + if desktop.uid.is_empty() || desktop.username.is_empty() { + *cache = None; + } else { + *cache = Some(ActiveUserLookupCache { + uid: desktop.uid.clone(), + username: desktop.username.clone(), + }); + } + } +} + +#[inline] +fn get_active_user_id_name_from_cache() -> Option<(String, String)> { + let cache = ACTIVE_USER_LOOKUP_CACHE.lock().ok()?; + let entry = cache.as_ref()?; + Some((entry.uid.clone(), entry.username.clone())) +} + thread_local! { // XDO context - created via libxdo-sys (which uses dynamic loading stub). // If libxdo is not available, xdo will be null and xdo-based functions become no-ops. @@ -789,6 +818,7 @@ pub fn start_os_service() { let mut last_restart = Instant::now(); while running.load(Ordering::SeqCst) { desktop.refresh(); + update_active_user_lookup_cache(&desktop); // Duplicate logic here with should_start_server // Login wayland will try to start a headless --server. @@ -861,13 +891,29 @@ pub fn start_os_service() { } #[inline] +/// Returns the cached active `(uid, username)` snapshot when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_user_id_name() -> (String, String) { + if let Some(id_name) = get_active_user_id_name_from_cache() { + return id_name; + } let vec_id_name = get_values_of_seat0(&[1, 2]); (vec_id_name[0].clone(), vec_id_name[1].clone()) } #[inline] +/// Returns the cached active uid when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_userid() -> String { + if let Some((uid, _)) = get_active_user_id_name_from_cache() { + return uid; + } + get_values_of_seat0(&[1])[0].clone() +} + +#[inline] +/// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache. +pub fn get_active_userid_fresh() -> String { get_values_of_seat0(&[1])[0].clone() } @@ -922,7 +968,12 @@ fn _get_display_manager() -> String { } #[inline] +/// Returns the cached active username when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_username() -> String { + if let Some((_, username)) = get_active_user_id_name_from_cache() { + return username; + } get_values_of_seat0(&[2])[0].clone() } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 4c09bbe9f..a755714f9 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -73,10 +73,19 @@ use winapi::{ }; use windows::Win32::{ Foundation::{CloseHandle as WinCloseHandle, HANDLE as WinHANDLE}, + Security::{ + GetTokenInformation as WinGetTokenInformation, IsWellKnownSid, TokenUser, + WinLocalSystemSid, TOKEN_QUERY as WIN_TOKEN_QUERY, TOKEN_USER, + }, System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, }, + System::Threading::{ + OpenProcess as WinOpenProcess, OpenProcessToken as WinOpenProcessToken, + QueryFullProcessImageNameW as WinQueryFullProcessImageNameW, + PROCESS_QUERY_LIMITED_INFORMATION as WIN_PROCESS_QUERY_LIMITED_INFORMATION, + }, }; use windows_service::{ define_windows_service, @@ -88,6 +97,14 @@ use windows_service::{ }; use winreg::{enums::*, RegKey}; +mod acl; +pub(crate) use acl::current_process_user_sid_string; +pub use acl::{ + set_path_permission, set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, + validate_path_for_portable_service_shmem_dir, +}; + pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window pub const EXPLORER_EXE: &'static str = "explorer.exe"; pub const SET_FOREGROUND_WINDOW: &'static str = "SET_FOREGROUND_WINDOW"; @@ -565,6 +582,55 @@ pub fn get_current_session_id(share_rdp: bool) -> DWORD { unsafe { get_current_session(if share_rdp { TRUE } else { FALSE }) } } +#[inline] +fn resolve_expected_active_session_id_for_service(session_id: u32) -> Option { + let share_rdp_enabled = is_share_rdp(); + if get_available_sessions(false) + .iter() + .any(|e| e.sid == session_id) + { + return Some(session_id); + } + let current_active_session = + unsafe { get_current_session(if share_rdp_enabled { TRUE } else { FALSE }) }; + if current_active_session == u32::MAX { + None + } else { + Some(current_active_session) + } +} + +#[inline] +fn authorize_service_scoped_ipc_connection( + stream: &ipc::Connection, + expected_active_session_id: Option, +) -> bool { + let (authorized, peer_pid, peer_session_id, peer_is_system) = + stream.service_authorization_status_for_session(expected_active_session_id); + if !authorized { + ipc::log_rejected_windows_ipc_connection( + crate::POSTFIX_SERVICE, + peer_pid, + peer_session_id, + expected_active_session_id, + peer_is_system, + ); + return false; + } + if let Err(err) = + ipc::ensure_peer_executable_matches_current_by_pid_opt(peer_pid, crate::POSTFIX_SERVICE) + { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + crate::POSTFIX_SERVICE, + peer_pid, + err + ); + return false; + } + true +} + extern "system" { fn BlockInput(v: BOOL) -> BOOL; } @@ -631,6 +697,15 @@ async fn run_service(_arguments: Vec) -> ResultType<()> { Ok(res) => match res { Some(Ok(stream)) => { let mut stream = ipc::Connection::new(stream); + // Keep IPC authorization consistent with the session we are currently serving. + // Recompute expected session right before authorization to avoid using a stale + // session_id after awaiting incoming.next(). + let expected_active_session_id = + resolve_expected_active_session_id_for_service(session_id); + if !authorize_service_scoped_ipc_connection(&stream, expected_active_session_id) + { + continue; + } if let Ok(Some(data)) = stream.next_timeout(1000).await { match data { ipc::Data::Close => { @@ -1141,6 +1216,22 @@ pub fn get_active_user_home() -> Option { None } +#[cfg(not(feature = "flutter"))] +#[inline] +pub fn portable_service_logon_helper_paths() -> Option<(PathBuf, PathBuf)> { + // Keep parity with history for now: derive LocalAppData from user profile path. + // If users report redirected/non-standard LocalAppData issues, switch to: + // `BaseDirs::new()?.data_local_dir()` for Known Folder-based resolution. + let user_dir = hbb_common::directories_next::UserDirs::new()?; + let dir = user_dir + .home_dir() + .join("AppData") + .join("Local") + .join("rustdesk-sciter"); + let dst = dir.join("rustdesk.exe"); + Some((dir, dst)) +} + pub fn is_prelogin() -> bool { let Some(username) = get_current_session_username() else { return false; @@ -2327,16 +2418,33 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst is_run_as_system, crate::username(), ); - let arg_elevate = if is_setup { + let mut arg_elevate = if is_setup { "--noinstall --elevate" } else { "--elevate" - }; - let arg_run_as_system = if is_setup { + } + .to_owned(); + let mut arg_run_as_system = if is_setup { "--noinstall --run-as-system" } else { "--run-as-system" - }; + } + .to_owned(); + let shmem_name_from_args = crate::portable_service::portable_service_shmem_name_from_args(); + if shmem_name_from_args.is_none() && crate::portable_service::has_portable_service_shmem_arg() { + log::error!("Invalid portable service shared memory argument, aborting elevation flow"); + // This is a malformed bootstrap argument in a privilege-sensitive path. + // Keep fail-closed process termination here to avoid continuing elevation + // with inconsistent shared-memory contract. + std::process::exit(1); + } + if let Some(shmem_name) = shmem_name_from_args { + let shmem_arg = crate::portable_service::portable_service_shmem_arg(&shmem_name); + arg_elevate.push(' '); + arg_elevate.push_str(&shmem_arg); + arg_run_as_system.push(' '); + arg_run_as_system.push_str(&shmem_arg); + } if is_root() { if is_run_as_system { log::info!("run portable service"); @@ -2347,7 +2455,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst Ok(elevated) => { if elevated { if !is_run_as_system { - if run_as_system(arg_run_as_system).is_ok() { + if run_as_system(arg_run_as_system.as_str()).is_ok() { std::process::exit(0); } else { log::error!( @@ -2358,7 +2466,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst } } else { if !is_elevate { - if let Ok(true) = elevate(arg_elevate) { + if let Ok(true) = elevate(arg_elevate.as_str()) { std::process::exit(0); } else { log::error!("Failed to elevate, error {}", io::Error::last_os_error()); @@ -2416,6 +2524,115 @@ pub fn is_elevated(process_id: Option) -> ResultType { } } +#[inline] +unsafe fn read_token_user_buffer(token: WinHANDLE, subject: &str) -> ResultType> { + let mut token_user_size = 0u32; + let get_info_result = WinGetTokenInformation(token, TokenUser, None, 0, &mut token_user_size); + match get_info_result { + Ok(()) => { + if token_user_size == 0 { + bail!( + "Failed to get {} token user size: unexpected zero buffer size", + subject + ); + } + } + Err(e) => { + // Allow expected size-probe failures if Windows still returns required size. + let is_insufficient_buffer = + e.code() == windows::core::HRESULT::from_win32(ERROR_INSUFFICIENT_BUFFER as u32); + let is_bad_length = + e.code() == windows::core::HRESULT::from_win32(ERROR_BAD_LENGTH as u32); + if (!is_insufficient_buffer && !is_bad_length) || token_user_size == 0 { + bail!("Failed to get {} token user size: {}", subject, e); + } + } + } + + let mut buffer = vec![0u8; token_user_size as usize]; + WinGetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr() as *mut core::ffi::c_void), + token_user_size, + &mut token_user_size, + ) + .map_err(|e| anyhow!("Failed to get {} token user: {}", subject, e))?; + + let min_size = std::mem::size_of::(); + if buffer.len() < min_size { + bail!( + "Failed to parse {} token user: buffer too small (got {}, need >= {})", + subject, + buffer.len(), + min_size + ); + } + Ok(buffer) +} + +/// Similar to `is_root()` / `is_local_system()` but for an arbitrary process. +/// +/// Returns `true` if the target process is running as LocalSystem (SID: S-1-5-18). +/// +/// TODO: After a few releases of real-world validation, consider replacing +/// the legacy `is_local_system()` with this implementation. +pub fn is_process_running_as_system(process_id: DWORD) -> ResultType { + unsafe { + let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) + .map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?; + + let mut token = WinHANDLE::default(); + let result = (|| -> ResultType { + WinOpenProcessToken(process, WIN_TOKEN_QUERY, &mut token) + .map_err(|e| anyhow!("Failed to open process {} token: {}", process_id, e))?; + + let token_subject = format!("process {}", process_id); + let buffer = read_token_user_buffer(token, token_subject.as_str())?; + let token_user: TOKEN_USER = + std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER); + Ok(IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool()) + })(); + + if !token.is_invalid() { + let _ = WinCloseHandle(token); + } + let _ = WinCloseHandle(process); + result + } +} + +pub fn get_process_executable_path(process_id: DWORD) -> ResultType { + const PROCESS_IMAGE_PATH_BUFFER_LEN: usize = 32 * 1024; + unsafe { + let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) + .map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?; + + let result = (|| -> ResultType { + let mut buffer = vec![0u16; PROCESS_IMAGE_PATH_BUFFER_LEN]; + let mut length = PROCESS_IMAGE_PATH_BUFFER_LEN as u32; + WinQueryFullProcessImageNameW( + process, + windows::Win32::System::Threading::PROCESS_NAME_FORMAT(0), + windows::core::PWSTR(buffer.as_mut_ptr()), + &mut length, + ) + .map_err(|e| anyhow!("Failed to query process {} image path: {}", process_id, e))?; + if length == 0 { + bail!( + "Failed to query process {} image path: empty result", + process_id + ); + } + buffer.truncate(length as usize); + Ok(PathBuf::from(OsString::from_wide(&buffer))) + })(); + + let _ = WinCloseHandle(process); + result + } +} + pub fn is_foreground_window_elevated() -> ResultType { unsafe { let mut process_id: DWORD = 0; @@ -2708,16 +2925,6 @@ pub fn create_process_with_logon(user: &str, pwd: &str, exe: &str, arg: &str) -> return Ok(()); } -pub fn set_path_permission(dir: &Path, permission: &str) -> ResultType<()> { - std::process::Command::new("icacls") - .arg(dir.as_os_str()) - .arg("/grant") - .arg(format!("*S-1-1-0:(OI)(CI){}", permission)) - .arg("/T") - .spawn()?; - Ok(()) -} - #[inline] fn str_to_device_name(name: &str) -> [u16; 32] { let mut device_name: Vec = wide_string(name); @@ -4281,6 +4488,87 @@ pub(super) fn get_pids_with_first_arg_by_wmic, S2: AsRef>( #[cfg(test)] mod tests { use super::*; + + // Test-only reusable Win32 HANDLE RAII helper. + // If a future non-test path needs the same pattern, move it out of this test module. + // + // This struct is similar to `hbb_common::platform::windows::RAIIHandle`, + // but `RAIIHandle` depends on `WinApi` crate, while this `HandleGuard` only depends on `windows` crate. + struct HandleGuard(WinHANDLE); + + impl HandleGuard { + #[inline] + fn new(handle: WinHANDLE) -> Self { + Self(handle) + } + + #[inline] + fn get(&self) -> WinHANDLE { + self.0 + } + } + + impl Drop for HandleGuard { + fn drop(&mut self) { + unsafe { + if !self.0.is_invalid() { + let _ = WinCloseHandle(self.0); + } + } + } + } + + #[test] + fn test_is_process_running_as_system_invalid_pid_errors() { + assert!(is_process_running_as_system(u32::MAX).is_err()); + } + + #[test] + fn test_is_process_running_as_system_matches_current_process_token_user() { + let pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() }; + let actual = is_process_running_as_system(pid).unwrap(); + + let expected = unsafe { + // Keep this test consistent: use only the `windows` crate APIs/types. + let process = HandleGuard::new( + WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + .expect("WinOpenProcess should succeed for current process"), + ); + let mut token = WinHANDLE::default(); + WinOpenProcessToken(process.get(), WIN_TOKEN_QUERY, &mut token) + .expect("WinOpenProcessToken should succeed for current process"); + let token = HandleGuard::new(token); + + let mut token_user_size = 0u32; + let _ = WinGetTokenInformation(token.get(), TokenUser, None, 0, &mut token_user_size); + assert_ne!(token_user_size, 0, "TokenUser size should be non-zero"); + + let mut buffer = vec![0u8; token_user_size as usize]; + WinGetTokenInformation( + token.get(), + TokenUser, + Some(buffer.as_mut_ptr() as *mut core::ffi::c_void), + token_user_size, + &mut token_user_size, + ) + .expect("WinGetTokenInformation(TokenUser) should succeed for current process"); + + let min_size = std::mem::size_of::(); + assert!( + buffer.len() >= min_size, + "TokenUser buffer too small (got {}, need >= {})", + buffer.len(), + min_size + ); + let token_user: TOKEN_USER = + std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER); + let expected = IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool(); + expected + }; + + assert_eq!(actual, expected); + } + #[test] fn test_uninstall_cert() { println!("uninstall driver certs: {:?}", cert::uninstall_cert()); diff --git a/src/platform/windows/acl.rs b/src/platform/windows/acl.rs new file mode 100644 index 000000000..682e66fed --- /dev/null +++ b/src/platform/windows/acl.rs @@ -0,0 +1,903 @@ +// https://learn.microsoft.com/en-us/windows/win32/secgloss/security-glossary + +use super::{read_token_user_buffer, wide_string, ResultType}; +use hbb_common::{anyhow::anyhow, bail}; +use std::{ + fs, io, + os::windows::{ffi::OsStrExt, fs::MetadataExt}, + path::Path, +}; +use windows::{ + core::{PCWSTR, PWSTR}, + Win32::{ + Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL}, + Security::{ + Authorization::{ + ConvertSidToStringSidW, ConvertStringSidToSidW, GetNamedSecurityInfoW, + SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS, + SE_FILE_OBJECT, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }, + ACE_FLAGS, ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, NO_INHERITANCE, + OBJECT_INHERIT_ACE, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + TOKEN_QUERY, TOKEN_USER, + }, + Storage::FileSystem::{FILE_ALL_ACCESS, FILE_GENERIC_WRITE}, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }, +}; + +const FILE_ATTRIBUTE_REPARSE_POINT_U32: u32 = 0x400; + +#[inline] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT_U32) != 0 +} + +fn apply_grant_sid_allow_ace_to_path( + path: &Path, + sid_ptr: *mut std::ffi::c_void, + access_mask: u32, + is_group: bool, + is_dir: bool, +) -> ResultType<()> { + // Merge mode: read existing DACL and append/replace ACE via SetEntriesInAclW. + // https://learn.microsoft.com/en-us/windows/win32/secauthz/modifying-the-acls-of-an-object-in-c-- + let mut old_dacl: *mut ACL = std::ptr::null_mut(); + let mut security_descriptor = PSECURITY_DESCRIPTOR::default(); + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let get_named_result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut old_dacl), + None, + &mut security_descriptor, + ) + }; + if get_named_result.0 != 0 { + bail!( + "GetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + get_named_result.0 + ); + } + let _sd_guard = LocalAllocGuard(security_descriptor.0); + + let inherit_flags = if is_dir { + ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0) + } else { + NO_INHERITANCE + }; + let explicit_access = [make_sid_trustee_entry( + sid_ptr, + access_mask, + inherit_flags, + is_group, + )]; + let old_acl_option = if old_dacl.is_null() { + None + } else { + Some(old_dacl as *const ACL) + }; + let mut new_acl: *mut ACL = std::ptr::null_mut(); + let set_entries_result = unsafe { + SetEntriesInAclW( + Some(explicit_access.as_slice()), + old_acl_option, + &mut new_acl, + ) + }; + if set_entries_result.0 != 0 { + bail!( + "SetEntriesInAclW failed for '{}': win32_error={}", + path.display(), + set_entries_result.0 + ); + } + if new_acl.is_null() { + bail!( + "SetEntriesInAclW returned null ACL for '{}'", + path.display() + ); + } + let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void); + + let set_named_result = unsafe { + SetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(new_acl), + None, + ) + }; + if set_named_result.0 != 0 { + bail!( + "SetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + set_named_result.0 + ); + } + Ok(()) +} + +/// Grants `Everyone` on `dir` recursively for helper/runtime files that must be +/// readable/executable across user contexts. +/// +/// `access_mask` is the Win32 file access mask to grant recursively. +pub fn set_path_permission(dir: &Path, access_mask: u32) -> ResultType<()> { + let metadata = fs::symlink_metadata(dir).map_err(|e| { + anyhow!( + "Failed to inspect ACL target directory '{}': {}", + dir.display(), + e + ) + })?; + if is_reparse_point(&metadata) { + bail!( + "ACL target directory is a reparse point and is rejected: '{}'", + dir.display() + ); + } + if !metadata.file_type().is_dir() { + bail!("ACL target is not a directory: '{}'", dir.display()); + } + + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0")?; + let mut stack = vec![dir.to_path_buf()]; + while let Some(path) = stack.pop() { + let metadata = fs::symlink_metadata(&path) + .map_err(|e| anyhow!("Failed to inspect ACL target '{}': {}", path.display(), e))?; + if is_reparse_point(&metadata) { + continue; + } + let is_dir = metadata.file_type().is_dir(); + apply_grant_sid_allow_ace_to_path( + &path, + everyone_sid.as_sid_ptr(), + access_mask, + true, + is_dir, + )?; + if !is_dir { + continue; + } + for entry in fs::read_dir(&path) + .map_err(|e| anyhow!("Failed to list ACL target dir '{}': {}", path.display(), e))? + { + let entry = entry.map_err(|e| { + anyhow!( + "Failed to read ACL target dir entry under '{}': {}", + path.display(), + e + ) + })?; + stack.push(entry.path()); + } + } + Ok(()) +} + +/// Returns the current process user SID as a standard SID string +/// (for example: `S-1-5-18`). +/// +/// Source: +/// - Official SID-to-string API (`ConvertSidToStringSidW`): +/// https://learn.microsoft.com/en-us/windows/win32/api/sddl/nf-sddl-convertsidtostringsidw +pub(crate) fn current_process_user_sid_string() -> ResultType { + let mut token = HANDLE::default(); + let result = (|| -> ResultType { + unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + .map_err(|e| anyhow!("Failed to open current process token: {}", e))?; + } + + let buffer = unsafe { read_token_user_buffer(token, "current process")? }; + let token_user: TOKEN_USER = + unsafe { std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER) }; + if token_user.User.Sid.0.is_null() { + bail!("Token SID is null"); + } + + let mut sid_string_ptr = PWSTR::null(); + unsafe { + ConvertSidToStringSidW(token_user.User.Sid, &mut sid_string_ptr).map_err(|e| { + anyhow!( + "ConvertSidToStringSidW failed for current process token SID: {}", + e + ) + })?; + } + if sid_string_ptr.is_null() { + bail!("ConvertSidToStringSidW returned null SID string pointer"); + } + let _sid_string_guard = LocalAllocGuard(sid_string_ptr.0 as *mut std::ffi::c_void); + unsafe { + sid_string_ptr + .to_string() + .map_err(|e| anyhow!("Failed to decode SID string as UTF-16: {}", e)) + } + })(); + + if !token.is_invalid() { + unsafe { + let _ = CloseHandle(token); + } + } + result +} + +/// Hardens ACLs for portable-service shared-memory path (directory or file). +/// +/// Why: +/// - Shared memory used by portable service carries runtime control/data and must not inherit +/// broad/default ACLs. +/// - We explicitly grant only trusted principals and remove broad groups to reduce local +/// privilege-boundary bypass risk. +/// +/// ACL policy applied via Win32 ACL APIs (`SetEntriesInAclW` + `SetNamedSecurityInfoW`): +/// - common (directory + file): +/// - `S-1-5-18` (LocalSystem): full control +/// - `S-1-5-32-544` (Built-in Administrators): full control +/// - `current_process_user_sid_string()` result: full control +/// - directory (`portable_service_shmem` parent): +/// - keep `Authenticated Users` directory-level write so other local accounts can +/// create their own runtime shmem files after account switching +/// - `FILE_GENERIC_WRITE + NO_INHERITANCE` means write/create on this directory itself; +/// it is intentionally not inherited by children. +/// Reference: +/// - File access rights: +/// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants +/// - ACE inheritance rules: +/// https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules +/// - remove `Everyone` and `Users` grants +/// - file (`shared_memory*` flink): +/// - remove broad grants: +/// - `S-1-1-0` (Everyone) +/// - `S-1-5-11` (Authenticated Users) +/// - `S-1-5-32-545` (Users) +/// +/// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids +pub fn set_path_permission_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> { + set_path_permission_for_portable_service_shmem_impl(path, true) +} + +#[inline] +pub fn validate_path_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> { + validate_portable_service_shmem_dir_target(path) +} + +#[inline] +pub fn set_path_permission_for_portable_service_shmem_file(path: &Path) -> ResultType<()> { + set_path_permission_for_portable_service_shmem_impl(path, false) +} + +#[derive(Debug)] +pub(super) struct LocalAllocGuard(*mut std::ffi::c_void); + +impl LocalAllocGuard { + #[inline] + pub(super) fn as_sid_ptr(&self) -> *mut std::ffi::c_void { + self.0 + } +} + +impl Drop for LocalAllocGuard { + fn drop(&mut self) { + if self.0.is_null() { + return; + } + // Buffers returned by ConvertStringSidToSidW / SetEntriesInAclW / + // ConvertSidToStringSidW are LocalAlloc-owned and must be LocalFree'ed. + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0))); + } + } +} + +#[inline] +pub(super) fn sid_string_to_local_alloc_guard(sid: &str) -> ResultType { + let sid_utf16 = wide_string(sid); + let mut sid_ptr = PSID::default(); + unsafe { + ConvertStringSidToSidW(PCWSTR::from_raw(sid_utf16.as_ptr()), &mut sid_ptr) + .map_err(|e| anyhow!("ConvertStringSidToSidW failed for '{}': {}", sid, e))?; + } + if sid_ptr.0.is_null() { + bail!("ConvertStringSidToSidW returned null SID for '{}'", sid); + } + Ok(LocalAllocGuard(sid_ptr.0)) +} + +#[inline] +fn make_sid_trustee_entry( + sid_ptr: *mut std::ffi::c_void, + access_permissions: u32, + inheritance: ACE_FLAGS, + is_group: bool, +) -> EXPLICIT_ACCESS_W { + // `is_group` is explicitly provided by the caller from the concrete SID semantic + // (e.g. Administrators/Authenticated Users => group, LocalSystem/current user => user). + EXPLICIT_ACCESS_W { + grfAccessPermissions: access_permissions, + grfAccessMode: SET_ACCESS, + grfInheritance: inheritance, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: Default::default(), + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: if is_group { + TRUSTEE_IS_GROUP + } else { + TRUSTEE_IS_USER + }, + // SAFETY: With TrusteeForm=TRUSTEE_IS_SID, ptstrName is interpreted as PSID. + ptstrName: PWSTR::from_raw(sid_ptr as *mut u16), + }, + } +} + +fn validate_portable_service_shmem_dir_target(path: &Path) -> ResultType<()> { + let metadata = fs::symlink_metadata(path).map_err(|e| { + anyhow!( + "Failed to inspect portable service shared-memory ACL directory '{}': {}", + path.display(), + e + ) + })?; + if is_reparse_point(&metadata) { + bail!( + "Portable service shared-memory ACL directory target is a reparse point and is rejected: '{}'", + path.display() + ); + } + if !metadata.file_type().is_dir() { + bail!( + "Portable service shared-memory ACL target is not a directory: '{}'", + path.display() + ); + } + Ok(()) +} + +fn set_path_permission_for_portable_service_shmem_impl( + path: &Path, + expect_dir: bool, +) -> ResultType<()> { + if expect_dir { + validate_portable_service_shmem_dir_target(path)?; + } else { + let metadata_result = fs::symlink_metadata(path); + match metadata_result { + Ok(metadata) => { + if metadata.file_type().is_dir() { + bail!( + "Portable service shared-memory ACL target is a directory, expected file-like path: '{}'", + path.display() + ); + } + if is_reparse_point(&metadata) { + bail!( + "Portable service shared-memory ACL file target is a reparse point and is rejected: '{}'", + path.display() + ); + } + } + Err(e) + if e.kind() == io::ErrorKind::NotFound + || e.kind() == io::ErrorKind::PermissionDenied => + { + // Keep going and let Win32 ACL APIs return the final OS error. + // `Path::exists()/is_file()` and metadata can collapse ACL-denied paths into + // a false "not found" signal under restricted directory ACLs. + } + Err(e) => { + bail!( + "Failed to inspect portable service shared-memory ACL target '{}': {}", + path.display(), + e + ); + } + } + } + + let user_sid = current_process_user_sid_string()?; + let local_system_sid = sid_string_to_local_alloc_guard("S-1-5-18")?; + let administrators_sid = sid_string_to_local_alloc_guard("S-1-5-32-544")?; + let current_user_sid = sid_string_to_local_alloc_guard(&user_sid)?; + let authenticated_users_sid = if expect_dir { + Some(sid_string_to_local_alloc_guard("S-1-5-11")?) + } else { + None + }; + + let inherit_flags = if expect_dir { + ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0) + } else { + NO_INHERITANCE + }; + let mut entries = vec![ + make_sid_trustee_entry( + local_system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + false, + ), + make_sid_trustee_entry( + administrators_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + true, + ), + make_sid_trustee_entry( + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + false, + ), + ]; + if let Some(auth_sid) = authenticated_users_sid.as_ref() { + // Keep the shared parent directory multi-user writable at directory level. + entries.push(make_sid_trustee_entry( + auth_sid.as_sid_ptr(), + FILE_GENERIC_WRITE.0, + NO_INHERITANCE, + true, + )); + } + + // Rebuild mode: build a fresh DACL (old ACL not merged) and apply as protected. + // This avoids carrying over broad legacy ACEs from inherited/default ACLs. + // Reference: + // - SetEntriesInAclW: + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setentriesinaclw + // - SetNamedSecurityInfoW (PROTECTED_DACL_SECURITY_INFORMATION): + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setnamedsecurityinfow + let mut new_acl: *mut ACL = std::ptr::null_mut(); + let set_entries_result = + unsafe { SetEntriesInAclW(Some(entries.as_slice()), None, &mut new_acl) }; + if set_entries_result.0 != 0 { + bail!( + "SetEntriesInAclW failed for '{}': win32_error={}", + path.display(), + set_entries_result.0 + ); + } + if new_acl.is_null() { + bail!( + "SetEntriesInAclW returned null ACL for '{}'", + path.display() + ); + } + let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void); + + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let security_info = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION; + let set_named_result = unsafe { + SetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + security_info, + None, + None, + Some(new_acl), + None, + ) + }; + if set_named_result.0 != 0 { + bail!( + "SetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + set_named_result.0 + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + current_process_user_sid_string, set_path_permission, + set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, sid_string_to_local_alloc_guard, + LocalAllocGuard, ResultType, + }; + use hbb_common::bail; + use std::{ + fs, + os::windows::{ffi::OsStrExt, fs::symlink_dir, fs::symlink_file}, + path::{Path, PathBuf}, + }; + use windows::{ + core::PCWSTR, + Win32::{ + Security::{ + AclSizeInformation, + Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}, + EqualSid as WinEqualSid, GetAce, GetAclInformation, GetSecurityDescriptorControl, + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, + DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, + }, + Storage::FileSystem::{ + FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, + }, + }, + }; + + const ACCESS_ALLOWED_ACE_TYPE_U8: u8 = 0; + + fn unique_acl_test_path(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rustdesk_acl_{}_{}_{}", + prefix, + std::process::id(), + hbb_common::rand::random::() + )) + } + + fn try_create_dir_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool { + match symlink_dir(target, link) { + Ok(()) => true, + Err(err) => { + eprintln!( + "skip {}: failed to create directory reparse point (symlink): {}", + test_name, err + ); + false + } + } + } + + fn try_create_file_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool { + match symlink_file(target, link) { + Ok(()) => true, + Err(err) => { + eprintln!( + "skip {}: failed to create file reparse point (symlink): {}", + test_name, err + ); + false + } + } + } + + fn get_file_dacl(path: &Path) -> ResultType<(*mut ACL, LocalAllocGuard)> { + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut sd = PSECURITY_DESCRIPTOR::default(); + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut dacl), + None, + &mut sd, + ) + }; + if result.0 != 0 { + bail!( + "GetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + result.0 + ); + } + if dacl.is_null() || sd.0.is_null() { + bail!("DACL/security descriptor missing for '{}'", path.display()); + } + Ok((dacl, LocalAllocGuard(sd.0))) + } + + fn has_allow_ace_with_mask( + dacl: *const ACL, + sid_ptr: *mut std::ffi::c_void, + mask: u32, + ) -> bool { + let mut info = ACL_SIZE_INFORMATION::default(); + if unsafe { + GetAclInformation( + dacl, + &mut info as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ) + } + .is_err() + { + return false; + } + for index in 0..info.AceCount { + let mut ace_ptr: *mut std::ffi::c_void = std::ptr::null_mut(); + if unsafe { GetAce(dacl, index, &mut ace_ptr) }.is_err() || ace_ptr.is_null() { + continue; + } + let header = unsafe { &*(ace_ptr as *const ACE_HEADER) }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE_U8 { + continue; + } + let allowed = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) }; + let ace_sid = PSID((&allowed.SidStart as *const u32) as *mut std::ffi::c_void); + if unsafe { WinEqualSid(PSID(sid_ptr), ace_sid) }.is_ok() + && (allowed.Mask & mask) == mask + { + return true; + } + } + false + } + + fn has_any_allow_ace_for_sid(dacl: *const ACL, sid_ptr: *mut std::ffi::c_void) -> bool { + has_allow_ace_with_mask(dacl, sid_ptr, 0) + } + + fn is_dacl_protected(sd: PSECURITY_DESCRIPTOR) -> bool { + let mut control: u16 = 0; + let mut revision: u32 = 0; + if unsafe { GetSecurityDescriptorControl(sd, &mut control, &mut revision) }.is_err() { + return false; + } + (control & SE_DACL_PROTECTED.0) != 0 + } + + #[test] + fn test_portable_service_shmem_dir_acl_policy() { + let dir = unique_acl_test_path("dir"); + fs::create_dir_all(&dir).unwrap(); + set_path_permission_for_portable_service_shmem_dir(&dir).unwrap(); + + let (dacl, sd_guard) = get_file_dacl(&dir).unwrap(); + let current_user_sid = + sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap(); + let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap(); + let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap(); + let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap(); + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap(); + + assert!(has_allow_ace_with_mask( + dacl, + system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + admin_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + auth_users_sid.as_sid_ptr(), + FILE_GENERIC_WRITE.0 + )); + assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr())); + assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr())); + assert!(is_dacl_protected(PSECURITY_DESCRIPTOR( + sd_guard.as_sid_ptr() + ))); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_policy() { + let dir = unique_acl_test_path("file"); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("shared_memory_portable_service_test"); + fs::write(&file, b"x").unwrap(); + set_path_permission_for_portable_service_shmem_file(&file).unwrap(); + + let (dacl, sd_guard) = get_file_dacl(&file).unwrap(); + let current_user_sid = + sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap(); + let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap(); + let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap(); + let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap(); + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap(); + + assert!(has_allow_ace_with_mask( + dacl, + system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + admin_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(!has_any_allow_ace_for_sid( + dacl, + auth_users_sid.as_sid_ptr() + )); + assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr())); + assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr())); + assert!(is_dacl_protected(PSECURITY_DESCRIPTOR( + sd_guard.as_sid_ptr() + ))); + + let _ = fs::remove_file(&file); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_set_path_permission_rx_applies_recursively() { + let root = unique_acl_test_path("set_path_permission"); + let child_dir = root.join("child"); + let child_file = child_dir.join("helper.exe"); + fs::create_dir_all(&child_dir).unwrap(); + fs::write(&child_file, b"x").unwrap(); + + if let Err(err) = set_path_permission(&root, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0) { + let text = err.to_string(); + let _ = fs::remove_file(&child_file); + let _ = fs::remove_dir_all(&root); + if text.contains("win32_error=5") || text.contains("Access is denied") { + eprintln!( + "skip test_set_path_permission_rx_applies_recursively: insufficient WRITE_DAC in current environment: {}", + text + ); + return; + } + panic!("set_path_permission failed unexpectedly: {}", text); + } + + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let rx_mask = FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0; + for target in [&root, &child_dir, &child_file] { + let (dacl, _sd_guard) = get_file_dacl(target).unwrap(); + assert!( + has_allow_ace_with_mask(dacl, everyone_sid.as_sid_ptr(), rx_mask), + "Everyone RX grant missing on '{}'", + target.display() + ); + } + + let _ = fs::remove_file(&child_file); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_dir_acl_rejects_file_target() { + let dir = unique_acl_test_path("dir_target_file"); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("target.txt"); + fs::write(&file, b"x").unwrap(); + let result = set_path_permission_for_portable_service_shmem_dir(&file); + assert!(result.is_err()); + let _ = fs::remove_file(&file); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_dir_target() { + let dir = unique_acl_test_path("file_target_dir"); + fs::create_dir_all(&dir).unwrap(); + let result = set_path_permission_for_portable_service_shmem_file(&dir); + assert!(result.is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_missing_target() { + let path = unique_acl_test_path("missing").join("shared_memory_missing"); + let result = set_path_permission_for_portable_service_shmem_file(&path); + assert!(result.is_err()); + } + + #[test] + fn test_set_path_permission_rejects_reparse_entrypoint() { + let root = unique_acl_test_path("reparse_entry"); + let real_dir = root.join("real"); + let link_dir = root.join("link"); + fs::create_dir_all(&real_dir).unwrap(); + if !try_create_dir_reparse_point( + &real_dir, + &link_dir, + "test_set_path_permission_rejects_reparse_entrypoint", + ) { + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission(&link_dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_dir(&link_dir); + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_dir_acl_rejects_reparse_target() { + let root = unique_acl_test_path("reparse_shmem_dir"); + let real_dir = root.join("real"); + let link_dir = root.join("link"); + fs::create_dir_all(&real_dir).unwrap(); + if !try_create_dir_reparse_point( + &real_dir, + &link_dir, + "test_portable_service_shmem_dir_acl_rejects_reparse_target", + ) { + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission_for_portable_service_shmem_dir(&link_dir); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_dir(&link_dir); + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_reparse_target() { + let root = unique_acl_test_path("reparse_shmem_file"); + let real_file = root.join("real.txt"); + let link_file = root.join("link.txt"); + fs::create_dir_all(&root).unwrap(); + fs::write(&real_file, b"x").unwrap(); + if !try_create_file_reparse_point( + &real_file, + &link_file, + "test_portable_service_shmem_file_acl_rejects_reparse_target", + ) { + let _ = fs::remove_file(&real_file); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission_for_portable_service_shmem_file(&link_file); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_file(&link_file); + let _ = fs::remove_file(&real_file); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/server.rs b/src/server.rs index dddc762bf..e11003faa 100644 --- a/src/server.rs +++ b/src/server.rs @@ -731,7 +731,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { if !synced { if conn.send(&Data::SyncConfig(None)).await.is_ok() { @@ -772,6 +772,12 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { log::error!("sync config to root failed: {}", e); - match crate::ipc::connect(1000, "_service").await { + match crate::ipc::connect_service(1000).await { Ok(mut _conn) => { conn = _conn; log::info!("reconnected to ipc_service"); diff --git a/src/server/connection.rs b/src/server/connection.rs index a960daac1..f5019e447 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -22,8 +22,6 @@ use crate::{ #[cfg(any(target_os = "android", target_os = "ios"))] use crate::{common::DEVICE_NAME, flutter::connection_manager::start_channel}; use cidr_utils::cidr::IpCidr; -#[cfg(target_os = "linux")] -use hbb_common::platform::linux::run_cmds; #[cfg(target_os = "android")] use hbb_common::protobuf::EnumOrUnknown; use hbb_common::{ @@ -4983,6 +4981,9 @@ pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { } #[cfg(not(any(target_os = "android", target_os = "ios")))] +// IPC bootstrap summary: +// - Resolve target CM socket (headless/non-headless, optional UID-scoped path on Linux). +// - Start CM when missing, then bridge bidirectional messages between this task and CM IPC. async fn start_ipc( mut rx_to_cm: mpsc::UnboundedReceiver, tx_from_cm: mpsc::UnboundedSender, @@ -4997,10 +4998,19 @@ async fn start_ipc( } sleep(1.).await; } + #[cfg(target_os = "linux")] + let headless_cm = crate::is_server() + && crate::platform::is_headless_allowed() + && linux_desktop_manager::is_headless(); + #[cfg(not(target_os = "linux"))] + let headless_cm = false; let mut stream = None; - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { - stream = Some(s); - } else { + if !headless_cm { + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); + } + } + if stream.is_none() { #[allow(unused_mut)] #[allow(unused_assignments)] let mut args = vec!["--cm"]; @@ -5010,75 +5020,123 @@ async fn start_ipc( // Cm run as user, wait until desktop session is ready. #[cfg(target_os = "linux")] - if crate::platform::is_headless_allowed() && linux_desktop_manager::is_headless() { + if headless_cm { let mut username = linux_desktop_manager::get_username(); loop { if !username.is_empty() { break; } + // `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes + // (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness. + // TODO: + // When `_rx_desktop_ready` is closed, `recv()` returns + // `None` immediately and this loop may spin if `username` remains empty. + // Keep behavior unchanged for now; if field reports appear, handle `Ok(None)` by + // breaking/returning to avoid hot-looping. let _res = timeout(1_000, _rx_desktop_ready.recv()).await; username = linux_desktop_manager::get_username(); } let uid = { - let output = run_cmds(&format!("id -u {}", &username))?; + let username_for_cmd = username.clone(); + let mut uid_cmd = hbb_common::tokio::process::Command::new("id"); + // TODO: + // Keep current behavior for now to minimize change risk. + // If usernames starting with '-' are observed in the field, prefer: + // `id -u -- ` to avoid option-parsing ambiguity. + // Already verified that `id -u -- ` works as expected on macOS and Ubuntu 24.04. + uid_cmd.arg("-u").arg(&username_for_cmd).kill_on_drop(true); + let output = timeout(10_000, uid_cmd.output()) + .await + .map_err(|_| anyhow!("Timed out querying uid for {}", username))? + .map_err(|e| anyhow!("Failed to run `id -u {}`: {}", username, e))?; + if !output.status.success() { + bail!("Failed to query uid for {}", username); + } + let output = String::from_utf8_lossy(&output.stdout); let output = output.trim(); - if output.is_empty() || !output.parse::().is_ok() { - bail!("Invalid username {}", &username); + if output.parse::().is_err() { + bail!("Invalid uid {}", output); } output.to_string() }; user = Some((uid, username)); args = vec!["--cm-no-ui"]; } - let run_done; - if crate::platform::is_root() { - let mut res = Ok(None); - for _ in 0..10 { - #[cfg(not(any(target_os = "linux")))] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user(args.clone()); - } - #[cfg(target_os = "linux")] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user( - args.clone(), - user.clone(), - None::<(&str, &str)>, - ); - } - if res.is_ok() { - break; - } - log::error!("Failed to run cm: {res:?}"); - sleep(1.).await; - } - if let Some(task) = res? { - super::CHILD_PROCESS.lock().unwrap().push(task); - } - run_done = true; - } else { - run_done = false; - } - if !run_done { - log::debug!("Start cm"); - super::CHILD_PROCESS - .lock() - .unwrap() - .push(crate::run_me(args)?); - } - for _ in 0..20 { - sleep(0.3).await; - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + #[cfg(target_os = "linux")] + let cm_uid: Option = match &user { + Some((uid, _)) => Some( + uid.parse::() + .map_err(|_| anyhow!("Invalid uid {}", uid))?, + ), + None => None, + }; + #[cfg(target_os = "linux")] + if let Some(uid) = cm_uid { + if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { stream = Some(s); - break; } } if stream.is_none() { - bail!("Failed to connect to connection manager"); + let run_done; + if crate::platform::is_root() { + let mut res = Ok(None); + for _ in 0..10 { + #[cfg(not(any(target_os = "linux")))] + { + log::debug!("Start cm"); + res = crate::platform::run_as_user(args.clone()); + } + #[cfg(target_os = "linux")] + { + log::debug!("Start cm"); + res = crate::platform::run_as_user( + args.clone(), + user.clone(), + None::<(&str, &str)>, + ); + } + if res.is_ok() { + break; + } + log::error!("Failed to run cm: {res:?}"); + sleep(1.).await; + } + if let Some(task) = res? { + super::CHILD_PROCESS.lock().unwrap().push(task); + } + run_done = true; + } else { + run_done = false; + } + if !run_done { + log::debug!("Start cm"); + super::CHILD_PROCESS + .lock() + .unwrap() + .push(crate::run_me(args)?); + } + for _ in 0..20 { + sleep(0.3).await; + #[cfg(target_os = "linux")] + { + if let Some(uid) = cm_uid { + if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { + stream = Some(s); + break; + } + continue; + } + } + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); + break; + } + } } } + if stream.is_none() { + bail!("Failed to connect to connection manager"); + } let _res = tx_stream_ready.send(()).await; let mut stream = stream.ok_or(anyhow!("none stream"))?; diff --git a/src/server/portable_service.rs b/src/server/portable_service.rs index 6f5695046..23b69a70c 100644 --- a/src/server/portable_service.rs +++ b/src/server/portable_service.rs @@ -1,3 +1,11 @@ +use crate::{ + ipc::{self, new_listener, Connection, Data, DataPortableService, IPC_TOKEN_LEN}, + platform::{ + set_path_permission, set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, + validate_path_for_portable_service_shmem_dir, + }, +}; use core::slice; use hbb_common::{ allow_err, @@ -15,26 +23,26 @@ use shared_memory::*; use std::{ mem::size_of, ops::{Deref, DerefMut}, - path::Path, - sync::{Arc, Mutex}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, time::Duration, }; use winapi::{ shared::minwindef::{BOOL, FALSE, TRUE}, um::winuser::{self, CURSORINFO, PCURSORINFO}, }; - -use crate::{ - ipc::{self, new_listener, Connection, Data, DataPortableService}, - platform::set_path_permission, -}; +use windows::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ}; use super::video_qos; const SIZE_COUNTER: usize = size_of::() * 2; const FRAME_ALIGN: usize = 64; -const ADDR_CURSOR_PARA: usize = 0; +const ADDR_IPC_TOKEN: usize = 0; +const ADDR_CURSOR_PARA: usize = ADDR_IPC_TOKEN + IPC_TOKEN_LEN; const ADDR_CURSOR_COUNTER: usize = ADDR_CURSOR_PARA + size_of::(); const ADDR_CAPTURER_PARA: usize = ADDR_CURSOR_COUNTER + SIZE_COUNTER; @@ -44,12 +52,186 @@ const ADDR_CAPTURE_FRAME_COUNTER: usize = ADDR_CAPTURE_WOULDBLOCK + size_of:: bool { + !name.is_empty() + && name.len() <= SHMEM_NAME_MAX_LEN + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +#[inline] +pub fn portable_service_shmem_arg(name: &str) -> String { + format!("{SHMEM_ARG_PREFIX}{name}") +} + +#[inline] +fn is_valid_portable_service_ipc_token(token: &str) -> bool { + token.len() == IPC_TOKEN_LEN + && token + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[inline] +fn read_ipc_token_from_shmem(shmem: &SharedMemory) -> Option { + if shmem.len() < ADDR_IPC_TOKEN + IPC_TOKEN_LEN { + log::error!( + "Portable service shared memory too small: len={}, need>={}", + shmem.len(), + ADDR_IPC_TOKEN + IPC_TOKEN_LEN + ); + return None; + } + unsafe { + let ptr = shmem.as_ptr().add(ADDR_IPC_TOKEN); + let bytes = slice::from_raw_parts(ptr, IPC_TOKEN_LEN); + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(IPC_TOKEN_LEN); + if end == 0 { + return None; + } + let token = std::str::from_utf8(&bytes[..end]).ok()?.to_owned(); + if is_valid_portable_service_ipc_token(&token) { + Some(token) + } else { + None + } + } +} + +#[inline] +fn validate_runtime_shmem_layout(shmem: &SharedMemory) -> ResultType<()> { + if shmem.len() < MIN_RUNTIME_SHMEM_LEN { + bail!( + "Portable service shared memory too small for runtime layout: len={}, need>={}", + shmem.len(), + MIN_RUNTIME_SHMEM_LEN + ); + } + Ok(()) +} + +#[inline] +fn is_valid_capture_frame_length(shmem_len: usize, frame_len: usize) -> bool { + let frame_capacity = shmem_len.saturating_sub(ADDR_CAPTURE_FRAME); + frame_len > 0 && frame_len <= frame_capacity +} + +#[inline] +fn shared_memory_flink_path_by_name(name: &str) -> ResultType { + let mut dir = crate::platform::user_accessible_folder()?; + dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone()); + dir = dir.join(SHMEM_PARENT_DIR); + Ok(dir.join(format!("shared_memory{}", name))) +} + +#[inline] +fn remove_shared_memory_flink_once(name: &str, log_on_error: bool, log_context: &str) -> bool { + let flink = match shared_memory_flink_path_by_name(name) { + Ok(path) => path, + Err(err) => { + if log_on_error { + log::warn!( + "{} failed to resolve portable service shared-memory flink path for '{}': {}", + log_context, + name, + err + ); + } + return false; + } + }; + match std::fs::remove_file(&flink) { + Ok(()) => { + log::info!( + "{} removed portable service shared-memory flink artifact: {:?}", + log_context, + flink + ); + true + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, + Err(err) => { + if log_on_error { + log::warn!( + "{} failed to remove portable service shared-memory flink artifact {:?}: {}", + log_context, + flink, + err + ); + } + false + } + } +} + +#[inline] +fn write_ipc_token_to_shmem(shmem: &SharedMemory, token: &str) -> ResultType<()> { + if !is_valid_portable_service_ipc_token(token) { + bail!("Invalid portable service ipc token"); + } + shmem.write(ADDR_IPC_TOKEN, token.as_bytes()); + Ok(()) +} + +#[inline] +fn clear_ipc_token_in_shmem(shmem: &SharedMemory) { + shmem.write(ADDR_IPC_TOKEN, &[0u8; IPC_TOKEN_LEN]); +} + +#[inline] +fn portable_service_arg_value_candidate_from_arg<'a>( + arg: &'a str, + prefix: &str, +) -> Option<&'a str> { + let mut value = arg.strip_prefix(prefix)?; + value = value.trim_start(); + value = value + .strip_prefix('"') + .or_else(|| value.strip_prefix('\'')) + .unwrap_or(value); + value = value.split_whitespace().next().unwrap_or_default(); + value = value.trim_matches(|c| c == '"' || c == '\''); + Some(value) +} + +#[inline] +pub fn portable_service_shmem_name_from_args() -> Option { + for arg in std::env::args() { + if let Some(value) = portable_service_arg_value_candidate_from_arg(&arg, SHMEM_ARG_PREFIX) { + if is_valid_portable_service_shmem_name(value) { + return Some(value.to_owned()); + } + log::error!( + "Invalid portable service shared memory name argument: '{}'", + value + ); + return None; + } + } + None +} + +#[inline] +pub fn has_portable_service_shmem_arg() -> bool { + std::env::args().any(|arg| arg.starts_with(SHMEM_ARG_PREFIX)) +} + pub struct SharedMemory { inner: Shmem, } @@ -92,7 +274,27 @@ impl SharedMemory { } }; log::info!("Create shared memory, size: {}, flink: {}", size, flink); - set_path_permission(Path::new(&flink), "F").ok(); + if let Err(err) = set_path_permission_for_portable_service_shmem_file(Path::new(&flink)) { + // Release shmem handle first so best-effort flink cleanup has a chance to succeed. + drop(shmem); + match std::fs::remove_file(&flink) { + Ok(()) => { + log::info!( + "Create cleanup removed portable service shared-memory flink artifact: {}", + flink + ); + } + Err(remove_err) if remove_err.kind() == std::io::ErrorKind::NotFound => {} + Err(remove_err) => { + log::warn!( + "Create cleanup failed to remove portable service shared-memory flink artifact {}: {}", + flink, + remove_err + ); + } + } + return Err(err); + } Ok(SharedMemory { inner: shmem }) } @@ -120,9 +322,18 @@ impl SharedMemory { fn flink(name: String) -> ResultType { let mut dir = crate::platform::user_accessible_folder()?; dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone()); - if !dir.exists() { - std::fs::create_dir(&dir)?; - set_path_permission(&dir, "F").ok(); + dir = dir.join(SHMEM_PARENT_DIR); + let parent_created = !dir.exists(); + if parent_created { + std::fs::create_dir_all(&dir)?; + } + if parent_created || crate::platform::is_root() { + // Harden parent ACL on first provisioning and periodically on SYSTEM path. + set_path_permission_for_portable_service_shmem_dir(&dir)?; + } else { + // Existing parents still need type/reparse validation. Non-SYSTEM callers may lack + // WRITE_DAC on a valid parent, so avoid rebuilding the ACL here. + validate_path_for_portable_service_shmem_dir(&dir)?; } Ok(dir .join(format!("shared_memory{}", name)) @@ -232,16 +443,45 @@ pub mod server { lazy_static::lazy_static! { static ref EXIT: Arc> = Default::default(); + static ref FORCE_EXIT_ARMED: AtomicBool = AtomicBool::new(false); } pub fn run_portable_service() { - let shmem = match SharedMemory::open_existing(SHMEM_NAME) { + let shmem_name = match portable_service_shmem_name_from_args() { + Some(name) => name, + None => { + if has_portable_service_shmem_arg() { + log::error!( + "Invalid portable service shared memory argument, aborting startup" + ); + } else { + log::error!( + "Missing portable service shared memory argument, aborting startup" + ); + } + return; + } + }; + let shmem = match SharedMemory::open_existing(&shmem_name) { Ok(shmem) => Arc::new(shmem), Err(e) => { log::error!("Failed to open existing shared memory: {:?}", e); return; } }; + if let Err(e) = validate_runtime_shmem_layout(shmem.as_ref()) { + log::error!("{}", e); + return; + } + let ipc_token = match read_ipc_token_from_shmem(shmem.as_ref()) { + Some(token) => token, + None => { + log::error!( + "Missing portable service ipc token in shared memory, aborting startup" + ); + return; + } + }; let shmem1 = shmem.clone(); let shmem2 = shmem.clone(); let mut threads = vec![]; @@ -251,17 +491,24 @@ pub mod server { threads.push(std::thread::spawn(|| { run_capture(shmem2); })); - threads.push(std::thread::spawn(|| { - run_ipc_client(); + threads.push(std::thread::spawn(move || { + run_ipc_client(ipc_token); })); - threads.push(std::thread::spawn(|| { + // Detached shutdown watchdog: + // - gives graceful shutdown/cleanup a short window + // - force-exits the process if workers are still stuck + std::thread::spawn(|| { run_exit_check(); - })); + }); let record_pos_handle = crate::input_service::try_start_record_cursor_pos(); + // Arm forced-exit watchdog only for worker join phase. + // Once join phase completes, cleanup should not be interrupted by forced exit. + FORCE_EXIT_ARMED.store(true, Ordering::SeqCst); for th in threads.drain(..) { th.join().ok(); log::info!("thread joined"); } + FORCE_EXIT_ARMED.store(false, Ordering::SeqCst); crate::input_service::try_stop_record_cursor_pos(); if let Some(handle) = record_pos_handle { @@ -270,16 +517,47 @@ pub mod server { Err(e) => log::error!("record_pos_handle join error {:?}", &e), } } + drop(shmem); + remove_shared_memory_flink_with_retry(&shmem_name); } fn run_exit_check() { + const FORCED_EXIT_DELAY: Duration = Duration::from_secs(3); loop { if EXIT.lock().unwrap().clone() { - std::thread::sleep(Duration::from_millis(50)); - std::process::exit(0); + break; } std::thread::sleep(Duration::from_millis(50)); } + // Fallback only: normal shutdown path should complete and process should exit naturally. + // This forced exit is a last resort when worker threads are stuck and graceful teardown + // does not finish in time. + std::thread::sleep(FORCED_EXIT_DELAY); + if FORCE_EXIT_ARMED.load(Ordering::SeqCst) { + log::warn!( + "Portable service shutdown watchdog fallback triggered: forcing process exit after {:?}", + FORCED_EXIT_DELAY + ); + std::process::exit(0); + } + } + + fn remove_shared_memory_flink_with_retry(name: &str) { + const MAX_RETRY: usize = 20; + const RETRY_INTERVAL: Duration = Duration::from_millis(200); + for attempt in 0..MAX_RETRY { + let is_last_attempt = attempt + 1 == MAX_RETRY; + if remove_shared_memory_flink_once(name, is_last_attempt, "SYSTEM cleanup") { + return; + } + if !is_last_attempt { + std::thread::sleep(RETRY_INTERVAL); + } + } + log::warn!( + "SYSTEM cleanup failed to remove portable service shared-memory flink artifact '{}' after retry", + name + ); } fn run_get_cursor_info(shmem: Arc) { @@ -386,6 +664,17 @@ pub mod server { match c.as_mut().map(|f| f.frame(spf)) { Some(Ok(f)) => match f { Frame::PixelBuffer(f) => { + let frame_capacity = shmem.len().saturating_sub(ADDR_CAPTURE_FRAME); + if f.data().len() > frame_capacity { + log::error!( + "Portable service capture frame exceeds shared memory capacity: frame_len={}, capacity={}, shmem_len={}", + f.data().len(), + frame_capacity, + shmem.len() + ); + *EXIT.lock().unwrap() = true; + return; + } utils::set_frame_info( &shmem, FrameInfo { @@ -436,17 +725,33 @@ pub mod server { } #[tokio::main(flavor = "current_thread")] - async fn run_ipc_client() { + async fn run_ipc_client(ipc_token: String) { use DataPortableService::*; let postfix = IPC_SUFFIX; match ipc::connect(1000, postfix).await { Ok(mut stream) => { + if let Err(err) = + ipc::portable_service_ipc_handshake_as_client(&mut stream, &ipc_token).await + { + log::error!("portable service ipc handshake failed: {}", err); + *EXIT.lock().unwrap() = true; + return; + } let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1))); let mut nack = 0; loop { + if *EXIT.lock().unwrap() { + log::info!("Portable service EXIT signaled, closing ipc client loop"); + stream + .send(&Data::DataPortableService(WillClose)) + .await + .ok(); + break; + } + tokio::select! { res = stream.next() => { match res { @@ -526,7 +831,11 @@ pub mod client { lazy_static::lazy_static! { static ref RUNNING: Arc> = Default::default(); + static ref STARTING: Arc> = Default::default(); + static ref STARTING_TOKEN: AtomicU64 = AtomicU64::new(0); static ref SHMEM: Arc>> = Default::default(); + static ref SHMEM_RUNTIME_NAME: Arc>> = Default::default(); + static ref IPC_RUNTIME_TOKEN: Arc>> = Default::default(); static ref SENDER : Mutex> = Mutex::new(client::start_ipc_server()); static ref QUICK_SUPPORT: Arc> = Default::default(); } @@ -536,12 +845,176 @@ pub mod client { Logon(String, String), } + fn has_running_portable_service_process() -> bool { + let app_exe = format!("{}.exe", crate::get_app_name().to_lowercase()); + !crate::platform::get_pids_of_process_with_first_arg(&app_exe, "--portable-service") + .is_empty() + } + + #[inline] + fn next_portable_service_shmem_name() -> String { + format!( + "{}_{}_{:08x}", + crate::portable_service::SHMEM_NAME, + std::process::id(), + hbb_common::rand::random::() + ) + } + + #[inline] + fn set_runtime_ipc_token(token: String) { + *IPC_RUNTIME_TOKEN.lock().unwrap() = Some(token); + } + + #[inline] + fn schedule_remove_runtime_shmem_flink_retry(name: String) { + std::thread::spawn(move || { + const MAX_RETRY: usize = 20; + const RETRY_INTERVAL: Duration = Duration::from_millis(200); + for _ in 0..MAX_RETRY { + std::thread::sleep(RETRY_INTERVAL); + if remove_shared_memory_flink_once(&name, false, "Client cleanup") { + return; + } + } + log::warn!( + "Failed to remove portable service shared-memory flink artifact '{}' after retry", + name + ); + }); + } + + #[inline] + fn clear_runtime_shmem_state() { + let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap(); + let mut shmem_lock = SHMEM.lock().unwrap(); + if let Some(shmem) = shmem_lock.as_mut() { + clear_ipc_token_in_shmem(shmem); + } + *shmem_lock = None; + let runtime_name = SHMEM_RUNTIME_NAME.lock().unwrap().take(); + *runtime_token = None; + drop(runtime_token); + drop(shmem_lock); + if let Some(name) = runtime_name.as_deref() { + if !remove_shared_memory_flink_once(name, true, "Client cleanup") { + schedule_remove_runtime_shmem_flink_retry(name.to_owned()); + } + } + } + + #[inline] + fn consume_runtime_ipc_token_if_match(candidate: &str) -> (bool, Option) { + let mut token = IPC_RUNTIME_TOKEN.lock().unwrap(); + if !token + .as_deref() + .is_some_and(|expected| ipc::constant_time_ipc_token_eq(expected, candidate)) + { + return (false, None); + } + let mut shmem_lock = SHMEM.lock().unwrap(); + let matched_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone(); + *token = None; + if let Some(shmem) = shmem_lock.as_mut() { + clear_ipc_token_in_shmem(shmem); + } + (true, matched_shmem_name) + } + + #[inline] + fn restore_runtime_ipc_token_after_failed_handshake( + token: &str, + expected_shmem_name: Option<&str>, + ) { + let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap(); + if let Some(current) = runtime_token.as_deref() { + if current != token { + log::debug!( + "Skip restoring portable service ipc token after handshake failure: runtime token has changed to a newer value" + ); + return; + } + } + let mut shmem_lock = SHMEM.lock().unwrap(); + let current_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone(); + if current_shmem_name.as_deref() != expected_shmem_name { + if runtime_token.as_deref() == Some(token) { + *runtime_token = None; + } + log::debug!( + "Skip restoring portable service ipc token after handshake failure: shared-memory instance has changed" + ); + return; + } + let shmem_write_error = if let Some(shmem) = shmem_lock.as_mut() { + write_ipc_token_to_shmem(shmem, token) + .err() + .map(|err| err.to_string()) + } else { + Some("shared memory unavailable".to_owned()) + }; + if let Some(err) = shmem_write_error { + if runtime_token.as_deref() == Some(token) { + *runtime_token = None; + } + log::warn!( + "Failed to restore portable service ipc token after handshake failure: {}", + err + ); + return; + } + *runtime_token = Some(token.to_owned()); + } + + #[inline] + fn schedule_starting_timeout_reset(launch_token: u64) { + std::thread::spawn(move || { + std::thread::sleep(PORTABLE_SERVICE_STARTUP_TIMEOUT); + let should_reset = { + // Guard against stale watchdogs from previous launches: + // only the watchdog that matches the latest STARTING_TOKEN may reset STARTING. + let current_token = STARTING_TOKEN.load(Ordering::SeqCst); + // Keep lock guards in explicit short scopes to make it obvious + // there is no nested lock ordering (and to avoid Copilot false positives). + let starting = { *STARTING.lock().unwrap() }; + let running = { *RUNNING.lock().unwrap() }; + current_token == launch_token && starting && !running + }; + if should_reset { + log::warn!( + "Portable service startup timeout before IPC ready, reset STARTING state" + ); + *STARTING.lock().unwrap() = false; + } + }); + } + + // Launch flow summary: + // 1) Prepare/reset runtime shared memory + IPC token. + // 2) Start helper process (direct or logon) with shmem argument. + // 3) Keep STARTING=true until IPC ping/pong marks RUNNING, or timeout watchdog resets it. pub(crate) fn start_portable_service(para: StartPara) -> ResultType<()> { log::info!("start portable service"); - if RUNNING.lock().unwrap().clone() { - bail!("already running"); - } - if SHMEM.lock().unwrap().is_none() { + let launch_token = { + // Keep lock guards in explicit short scopes to make it obvious + // there is no nested lock ordering (and to avoid Copilot false positives). + let running = { *RUNNING.lock().unwrap() }; + let mut starting = STARTING.lock().unwrap(); + if *starting && !running && !has_running_portable_service_process() { + log::warn!( + "Detected stale portable service STARTING state without running process, reset it" + ); + *starting = false; + } + if *starting || running { + bail!("already running"); + } + *starting = true; + STARTING_TOKEN.fetch_add(1, Ordering::SeqCst) + 1 + }; + let start_result = (|| -> ResultType<()> { + clear_runtime_shmem_state(); + let mut shmem_lock = SHMEM.lock().unwrap(); let displays = scrap::Display::all()?; if displays.is_empty() { bail!("no display available!"); @@ -558,84 +1031,153 @@ pub mod client { } } } - let shmem_size = utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align); + let shmem_size = + utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align).max(MIN_RUNTIME_SHMEM_LEN); + let shmem_name = next_portable_service_shmem_name(); + if !is_valid_portable_service_shmem_name(&shmem_name) { + bail!("Generated invalid portable service shared memory name"); + } + let ipc_token = ipc::generate_one_time_ipc_token()?; // os error 112, no enough space - *SHMEM.lock().unwrap() = Some(crate::portable_service::SharedMemory::create( - crate::portable_service::SHMEM_NAME, + *shmem_lock = Some(crate::portable_service::SharedMemory::create( + &shmem_name, shmem_size, )?); + *SHMEM_RUNTIME_NAME.lock().unwrap() = Some(shmem_name); shutdown_hooks::add_shutdown_hook(drop_portable_service_shared_memory); - } - if let Some(shmem) = SHMEM.lock().unwrap().as_mut() { - unsafe { - libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); - } - } - match para { - StartPara::Direct => { - if let Err(e) = crate::platform::run_background( - &std::env::current_exe()?.to_string_lossy().to_string(), - "--portable-service", - ) { - *SHMEM.lock().unwrap() = None; - bail!("Failed to run portable service process: {}", e); + let shmem_name = SHMEM_RUNTIME_NAME + .lock() + .unwrap() + .clone() + .ok_or_else(|| anyhow!("portable service shared memory name is unavailable"))?; + let init_token_result = if let Some(shmem) = shmem_lock.as_mut() { + unsafe { + libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); } + write_ipc_token_to_shmem(shmem, &ipc_token) + } else { + Ok(()) + }; + if let Err(e) = init_token_result { + drop(shmem_lock); + clear_runtime_shmem_state(); + bail!( + "Failed to initialize portable service ipc token in shared memory: {}", + e + ); + }; + drop(shmem_lock); + set_runtime_ipc_token(ipc_token.clone()); + let portable_service_arg = format!( + "--portable-service {}", + crate::portable_service::portable_service_shmem_arg(&shmem_name) + ); + { + let _sender = SENDER.lock().unwrap(); } - StartPara::Logon(username, password) => { - #[allow(unused_mut)] - let mut exe = std::env::current_exe()?.to_string_lossy().to_string(); - #[cfg(feature = "flutter")] - { - if let Some(dir) = Path::new(&exe).parent() { - if set_path_permission(Path::new(dir), "RX").is_err() { - *SHMEM.lock().unwrap() = None; - bail!("Failed to set permission of {:?}", dir); + match para { + StartPara::Direct => { + match crate::platform::run_background( + &std::env::current_exe()?.to_string_lossy().to_string(), + &portable_service_arg, + ) { + Ok(true) => {} + Ok(false) => { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process"); + } + Err(e) => { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process: {}", e); } } } - #[cfg(not(feature = "flutter"))] - match hbb_common::directories_next::UserDirs::new() { - Some(user_dir) => { - let dir = user_dir - .home_dir() - .join("AppData") - .join("Local") - .join("rustdesk-sciter"); - if std::fs::create_dir_all(&dir).is_ok() { - let dst = dir.join("rustdesk.exe"); - if std::fs::copy(&exe, &dst).is_ok() { - if dst.exists() { - if set_path_permission(&dir, "RX").is_ok() { - exe = dst.to_string_lossy().to_string(); - } - } + StartPara::Logon(username, password) => { + #[allow(unused_mut)] + let mut exe = std::env::current_exe()?.to_string_lossy().to_string(); + #[cfg(feature = "flutter")] + { + if let Some(dir) = Path::new(&exe).parent() { + if let Err(err) = set_path_permission( + Path::new(dir), + FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0, + ) { + clear_runtime_shmem_state(); + bail!("Failed to set permission of {:?}: {}", dir, err); } } } - None => {} - } - if let Err(e) = crate::platform::windows::create_process_with_logon( - username.as_str(), - password.as_str(), - &exe, - "--portable-service", - ) { - *SHMEM.lock().unwrap() = None; - bail!("Failed to run portable service process: {}", e); + #[cfg(not(feature = "flutter"))] + if let Some((dir, dst)) = + crate::platform::windows::portable_service_logon_helper_paths() + { + let cleanup_helper_artifacts = || { + if Path::new(&exe) != dst { + std::fs::remove_file(&dst).ok(); + } + std::fs::remove_dir(&dir).ok(); + }; + let mut use_logon_helper_exe = false; + if let Err(err) = std::fs::create_dir_all(&dir) { + log::warn!( + "Failed to create portable service logon helper dir {:?}: {}", + dir, + err + ); + } else if let Err(err) = std::fs::copy(&exe, &dst) { + log::warn!( + "Failed to copy portable service logon helper binary from '{}' to {:?}: {}", + exe, + dst, + err + ); + cleanup_helper_artifacts(); + } else if !dst.exists() { + log::warn!( + "Portable service logon helper binary missing after copy: {:?}", + dst + ); + cleanup_helper_artifacts(); + } else if let Err(err) = + set_path_permission(&dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0) + { + log::warn!( + "Failed to set portable service logon helper path permission for {:?}: {}", + dir, + err + ); + cleanup_helper_artifacts(); + } else { + use_logon_helper_exe = true; + } + if use_logon_helper_exe { + exe = dst.to_string_lossy().to_string(); + } + } + if let Err(e) = crate::platform::windows::create_process_with_logon( + username.as_str(), + password.as_str(), + &exe, + &portable_service_arg, + ) { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process: {}", e); + } } } + schedule_starting_timeout_reset(launch_token); + Ok(()) + })(); + if start_result.is_err() { + *STARTING.lock().unwrap() = false; } - let _sender = SENDER.lock().unwrap(); - Ok(()) + start_result } pub extern "C" fn drop_portable_service_shared_memory() { // https://stackoverflow.com/questions/35980148/why-does-an-atexit-handler-panic-when-it-accesses-stdout // Please make sure there is no print in the call stack - let mut lock = SHMEM.lock().unwrap(); - if lock.is_some() { - *lock = None; - } + clear_runtime_shmem_state(); } pub fn set_quick_support(v: bool) { @@ -655,7 +1197,11 @@ pub mod client { let mut option = SHMEM.lock().unwrap(); if let Some(shmem) = option.as_mut() { unsafe { - libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); + libc::memset( + shmem.as_ptr().add(ADDR_CURSOR_PARA) as _, + 0, + shmem.len().saturating_sub(ADDR_CURSOR_PARA) as _, + ); } utils::set_para( shmem, @@ -702,6 +1248,19 @@ pub mod client { if utils::counter_ready(base.add(ADDR_CAPTURE_FRAME_COUNTER)) { let frame_info_ptr = shmem.as_ptr().add(ADDR_CAPTURE_FRAME_INFO); let frame_info = frame_info_ptr as *const FrameInfo; + let frame_len = (*frame_info).length; + if !is_valid_capture_frame_length(shmem.len(), frame_len) { + log::error!( + "Portable service frame length exceeds shared memory capacity: frame_len={}, shmem_len={}, frame_addr={}", + frame_len, + shmem.len(), + ADDR_CAPTURE_FRAME + ); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid portable service frame length".to_string(), + )); + } if (*frame_info).width != self.width || (*frame_info).height != self.height { log::info!( "skip frame, ({},{}) != ({},{})", @@ -716,7 +1275,7 @@ pub mod client { )); } let frame_ptr = base.add(ADDR_CAPTURE_FRAME); - let data = slice::from_raw_parts(frame_ptr, (*frame_info).length); + let data = slice::from_raw_parts(frame_ptr, frame_len); Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA( data, self.width, @@ -778,10 +1337,49 @@ pub mod client { Some(result) = incoming.next() => { match result { Ok(stream) => { + let mut stream = Connection::new(stream); + if !ipc::authorize_windows_portable_service_ipc_connection( + &stream, postfix, + ) { + continue; + } + let mut consumed_token: Option = None; + let mut consumed_token_shmem_name: Option = None; + let handshake_result = + ipc::portable_service_ipc_handshake_as_server( + &mut stream, + |token| { + let (matched, matched_shmem_name) = + consume_runtime_ipc_token_if_match(token); + if matched { + consumed_token = Some(token.to_owned()); + consumed_token_shmem_name = matched_shmem_name; + true + } else { + false + } + }, + ) + .await; + if let Err(err) = handshake_result { + if let Some(token) = consumed_token.as_deref() { + restore_runtime_ipc_token_after_failed_handshake( + token, + consumed_token_shmem_name.as_deref(), + ); + *STARTING.lock().unwrap() = false; + } + log::warn!( + "Rejected portable service ipc connection due to token handshake failure: postfix={}, err={}", + postfix, + err + ); + continue; + } log::info!("Got portable service ipc connection"); let rx_clone = rx.clone(); tokio::spawn(async move { - let mut stream = Connection::new(stream); + let mut stream = stream; let postfix = postfix.to_owned(); let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1))); let mut nack = 0; @@ -805,6 +1403,7 @@ pub mod client { Pong => { nack = 0; *RUNNING.lock().unwrap() = true; + *STARTING.lock().unwrap() = false; }, ConnCount(None) => { if !quick_support { @@ -841,6 +1440,7 @@ pub mod client { } } *RUNNING.lock().unwrap() = false; + *STARTING.lock().unwrap() = false; }); } Err(err) => { @@ -990,3 +1590,23 @@ pub struct FrameInfo { width: usize, height: usize, } + +#[cfg(test)] +mod tests { + use super::{is_valid_capture_frame_length, ADDR_CAPTURE_FRAME}; + + #[test] + fn test_is_valid_capture_frame_length_rejects_zero_length() { + assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 1024, 0)); + } + + #[test] + fn test_is_valid_capture_frame_length_rejects_out_of_bounds_length() { + assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 17)); + } + + #[test] + fn test_is_valid_capture_frame_length_accepts_in_bounds_length() { + assert!(is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 16)); + } +} diff --git a/src/server/uinput.rs b/src/server/uinput.rs index a808b4aaa..a1947d79f 100644 --- a/src/server/uinput.rs +++ b/src/server/uinput.rs @@ -185,9 +185,13 @@ pub mod client { pub mod service { use super::*; use hbb_common::lazy_static; + #[cfg(target_os = "linux")] + use parity_tokio_ipc::Connection as RawIpcConnection; use scrap::wayland::{ pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop, }; + #[cfg(target_os = "linux")] + use std::os::unix::io::AsRawFd; use std::{collections::HashMap, sync::Mutex}; lazy_static::lazy_static! { @@ -602,7 +606,10 @@ pub mod service { } DataKeyboard::KeyDown(enigo::Key::Raw(code)) => { if *code < 8 { - log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code); + log::error!( + "Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", + code + ); } else { let down_event = InputEvent::new(EventType::KEY, *code - 8, 1); allow_err!(keyboard.emit(&[down_event])); @@ -610,7 +617,10 @@ pub mod service { } DataKeyboard::KeyUp(enigo::Key::Raw(code)) => { if *code < 8 { - log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code); + log::error!( + "Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", + code + ); } else { let up_event = InputEvent::new(EventType::KEY, *code - 8, 0); allow_err!(keyboard.emit(&[up_event])); @@ -909,6 +919,35 @@ pub mod service { }); } + #[cfg(target_os = "linux")] + fn authorize_uinput_peer(postfix: &str, stream: &RawIpcConnection) -> bool { + if !hbb_common::config::is_service_ipc_postfix(postfix) { + return true; + } + let peer_uid = ipc::peer_uid_from_fd(stream.as_raw_fd()); + let active_uid = crate::platform::linux::get_active_userid_fresh() + .trim() + .parse::() + .ok(); + let authorized = + peer_uid.is_some_and(|uid| ipc::is_allowed_service_peer_uid(uid, active_uid)); + if !authorized { + crate::ipc::log_rejected_uinput_connection(postfix, peer_uid, active_uid); + return false; + } + if let Err(err) = + ipc::ensure_peer_executable_matches_current_by_fd(stream.as_raw_fd(), postfix) + { + log::warn!( + "Rejected connection on protected uinput ipc channel due to executable mismatch: postfix={}, err={}", + postfix, + err + ); + return false; + } + true + } + /// Start uinput service. async fn start_service(postfix: &str, handler: F) { match new_listener(postfix).await { @@ -916,6 +955,10 @@ pub mod service { while let Some(result) = incoming.next().await { match result { Ok(stream) => { + #[cfg(target_os = "linux")] + if !authorize_uinput_peer(postfix, &stream) { + continue; + } log::debug!("Got new connection of uinput ipc {}", postfix); handler(Connection::new(stream)); } From b757e97c11bf5e6b653acbd2fe74515f239b5947 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 10 May 2026 10:02:42 +0800 Subject: [PATCH 17/36] fix(translation): ja (#14993) Signed-off-by: fufesou --- src/lang/ja.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 20caca0a7..b55a6664f 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -739,7 +739,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Changelog", "更新履歴"), ("keep-awake-during-outgoing-sessions-label", "送信セッション中は、画面のスリープを無効化する"), ("keep-awake-during-incoming-sessions-label", "受信セッション中は、画面のスリープを無効化する"), - ("Continue with {}", "{}で続行する"), + ("Continue with {}", "{} で続行する"), ("Display Name", "表示名"), ("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"), ("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"), From 9c831dc59bd08d387db99283d72c7947602d3e23 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 10 May 2026 10:08:29 +0800 Subject: [PATCH 18/36] fix(fs): file transfer, reconnect, restore dir (#14925) * fix(fs): file transfer, reconnect, restore dir Signed-off-by: fufesou * fix(fs): simple refactor Signed-off-by: fufesou * fix(fs): simple refactor Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/models/file_model.dart | 59 +++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 35001cbf2..7d91b03b3 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -391,14 +391,30 @@ class FileController { await Future.delayed(Duration(milliseconds: 100)); - final dir = (await bind.sessionGetPeerOption( + final savedDir = (await bind.sessionGetPeerOption( sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir")); - openDirectory(dir.isEmpty ? options.value.home : dir); + Future tryOpenReadyDirs() async { + final dirs = { + if (directory.value.path.isNotEmpty) directory.value.path, + if (savedDir.isNotEmpty) savedDir, + options.value.home, + }; + for (final dir in dirs) { + if (await _openDirectoryPath(dir, isBack: true)) { + return true; + } + } + return false; + } + + var opened = await tryOpenReadyDirs(); await Future.delayed(Duration(seconds: 1)); - if (directory.value.path.isEmpty) { - openDirectory(options.value.home); + if (!opened) { + // The peer may become ready during the reconnect delay, so retry the + // same candidates instead of only retrying the default home directory. + await tryOpenReadyDirs(); } } @@ -429,19 +445,23 @@ class FileController { }); } - Future refresh() async { - await openDirectory(directory.value.path); + Future refresh() async { + // "." can be both a refresh command and a real remote directory path. + // Refresh must bypass openDirectory's command dispatch to avoid recursion. + return await _openDirectoryPath(directory.value.path, isBack: true); } - Future openDirectory(String path, {bool isBack = false}) async { - if (path == ".") { - refresh(); - return; + Future openDirectory(String path, {bool isBack = false}) async { + if (!isBack && path == ".") { + return await refresh(); } - if (path == "..") { - goToParentDirectory(); - return; + if (!isBack && path == "..") { + return await _goToParentDirectory(isBack: isBack); } + return await _openDirectoryPath(path, isBack: isBack); + } + + Future _openDirectoryPath(String path, {bool isBack = false}) async { if (!isBack) { pushHistory(); } @@ -458,8 +478,10 @@ class FileController { final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden); fd.format(isWindows, sort: sortBy.value); directory.value = fd; + return true; } catch (e) { debugPrint("Failed to openDirectory $path: $e"); + return false; } } @@ -487,19 +509,22 @@ class FileController { goBack(); return; } - openDirectory(path, isBack: true); + unawaited(_openDirectoryPath(path, isBack: true).then((_) {})); } void goToParentDirectory() { + unawaited(_goToParentDirectory().then((_) {})); + } + + Future _goToParentDirectory({bool isBack = false}) async { final isWindows = options.value.isWindows; final dirPath = directory.value.path; var parent = PathUtil.dirname(dirPath, isWindows); // specially for C:\, D:\, goto '/' if (parent == dirPath && isWindows) { - openDirectory('/'); - return; + return await _openDirectoryPath('/', isBack: isBack); } - openDirectory(parent); + return await _openDirectoryPath(parent, isBack: isBack); } // TODO deprecated this From 0e4b91b8d7c352f56d7aca829e944eba747ac804 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 11 May 2026 12:58:01 +0800 Subject: [PATCH 19/36] =?UTF-8?q?Harden=20os=20password=20=EF=BC=88termina?= =?UTF-8?q?l=20windows=20and=20headless=20linux)=20anti=20brute=20force=20?= =?UTF-8?q?(#14985)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): terminal, preauth bruteforce Signed-off-by: fufesou * fix(linux): headless, preauth bruteforce Signed-off-by: fufesou * fix(linux): headless, OS login, minimal fix Signed-off-by: fufesou * Terminal session, click-only Signed-off-by: fufesou * Simple refactor, logs Signed-off-by: fufesou * harden os password, better scoped failure set Signed-off-by: fufesou * harden os password, ip failure count Signed-off-by: fufesou * Check prelogin before starting cm Signed-off-by: fufesou * Isolate terminal OS login failure tracking Terminal OS login no longer reads or updates the default RustDesk per-IP failure bucket. It now uses only the OS credential policy, while RustDesk password attempts keep using the existing LOGIN_FAILURES[0] bucket. Signed-off-by: fufesou --------- Signed-off-by: fufesou --- src/platform/linux_desktop_manager.rs | 86 +++++- src/server.rs | 1 + src/server/connection.rs | 384 ++++++++++++++++++++++---- src/server/login_failure_check.rs | 231 ++++++++++++++++ 4 files changed, 633 insertions(+), 69 deletions(-) create mode 100644 src/server/login_failure_check.rs diff --git a/src/platform/linux_desktop_manager.rs b/src/platform/linux_desktop_manager.rs index 03f1f6250..0a512939b 100644 --- a/src/platform/linux_desktop_manager.rs +++ b/src/platform/linux_desktop_manager.rs @@ -2,7 +2,7 @@ use super::{linux::*, ResultType}; use crate::client::{ LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER, LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND, - LOGIN_MSG_DESKTOP_XSESSION_FAILED, + LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG, }; use hbb_common::{ allow_err, bail, log, @@ -94,6 +94,49 @@ fn detect_headless() -> Option<&'static str> { None } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum XSessionStartErrorKind { + Auth, + Env, +} + +const XSESSION_AUTH_FAILURE_DETAIL: &str = "authentication failed"; + +#[derive(Debug)] +struct XSessionStartError { + kind: XSessionStartErrorKind, + detail: String, +} + +impl XSessionStartError { + fn auth(detail: String) -> Self { + Self { + kind: XSessionStartErrorKind::Auth, + detail, + } + } + + fn env(detail: String) -> Self { + Self { + kind: XSessionStartErrorKind::Env, + detail, + } + } +} + +impl std::fmt::Display for XSessionStartError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.detail) + } +} + +fn map_xsession_start_error_to_login_msg(kind: XSessionStartErrorKind) -> &'static str { + match kind { + XSessionStartErrorKind::Auth => LOGIN_MSG_PASSWORD_WRONG, + XSessionStartErrorKind::Env => LOGIN_MSG_DESKTOP_XSESSION_FAILED, + } +} + pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { debug_assert!(crate::is_server()); if _username.is_empty() { @@ -136,14 +179,21 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { } } Err(e) => { - log::error!("Failed to start xsession {}", e); - LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned() + match e.kind { + XSessionStartErrorKind::Auth => { + log::warn!("Failed to authenticate xsession user {}", e); + } + XSessionStartErrorKind::Env => { + log::error!("Failed to start xsession {}", e); + } + } + map_xsession_start_error_to_login_msg(e.kind).to_owned() } } } } -fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bool)> { +fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> { let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); if let Some(desktop_manager) = &mut (*desktop_manager) { if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() { @@ -161,7 +211,9 @@ fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bo desktop_manager.is_running(), )) } else { - bail!(crate::client::LOGIN_MSG_DESKTOP_NOT_INITED); + Err(XSessionStartError::env( + crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(), + )) } } @@ -247,10 +299,15 @@ impl DesktopManager { self.is_child_running.load(Ordering::SeqCst) } - fn try_start_x_session(&mut self, username: &str, password: &str) -> ResultType<()> { + fn try_start_x_session( + &mut self, + username: &str, + password: &str, + ) -> Result<(), XSessionStartError> { match get_user_by_name(username) { Some(userinfo) => { - let mut client = pam::Client::with_password(&pam_get_service_name())?; + let mut client = pam::Client::with_password(&pam_get_service_name()) + .map_err(|e| XSessionStartError::env(format!("failed to init pam client, {}", e)))?; client .conversation_mut() .set_credentials(username, password); @@ -267,17 +324,24 @@ impl DesktopManager { Ok(()) } Err(e) => { - bail!("failed to start x session, {}", e); + Err(XSessionStartError::env(format!( + "failed to start x session, {}", + e + ))) } } } - Err(e) => { - bail!("failed to check user pass for {}, {}", username, e); + Err(_e) => { + Err(XSessionStartError::auth( + XSESSION_AUTH_FAILURE_DETAIL.to_owned(), + )) } } } None => { - bail!("failed to get userinfo of {}", username); + Err(XSessionStartError::auth( + XSESSION_AUTH_FAILURE_DETAIL.to_owned(), + )) } } } diff --git a/src/server.rs b/src/server.rs index e11003faa..86f7b5396 100644 --- a/src/server.rs +++ b/src/server.rs @@ -67,6 +67,7 @@ pub mod input_service { } mod connection; +mod login_failure_check; pub mod display_service; #[cfg(windows)] pub mod portable_service; diff --git a/src/server/connection.rs b/src/server/connection.rs index f5019e447..538503d9c 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1,3 +1,8 @@ +#[cfg(target_os = "windows")] +use super::login_failure_check::try_acquire_os_credential_login_gate; +use super::login_failure_check::{ + evaluate_os_credential_policy, record_os_credential_failure, FailureScope, +}; use super::{input_service::*, *}; #[cfg(feature = "unix-file-copy-paste")] use crate::clipboard::try_empty_clipboard_files; @@ -82,6 +87,9 @@ lazy_static::lazy_static! { static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); } +#[cfg(target_os = "windows")] +const TERMINAL_OS_LOGIN_FAILED_MSG: &str = "Incorrect username or password."; + fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; @@ -94,6 +102,32 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { x == 0 } +#[cfg(target_os = "linux")] +fn should_check_linux_headless_os_auth_before_desktop_start( + is_headless_allowed: bool, + username: &str, +) -> bool { + is_headless_allowed + && !username.trim().is_empty() + && linux_desktop_manager::get_username().is_empty() +} + +#[cfg(target_os = "linux")] +fn should_record_linux_headless_os_auth_failure( + is_headless_allowed: bool, + username: &str, + err_msg: &str, +) -> bool { + is_headless_allowed + && !username.trim().is_empty() + && err_msg == crate::client::LOGIN_MSG_PASSWORD_WRONG +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn should_use_terminal_os_login_scope(is_terminal: bool, os_login_username: &str) -> bool { + cfg!(target_os = "windows") && is_terminal && !os_login_username.trim().is_empty() +} + #[cfg(any(target_os = "windows", target_os = "linux"))] lazy_static::lazy_static! { static ref WALLPAPER_REMOVER: Arc>> = Default::default(); @@ -1497,6 +1531,9 @@ impl Connection { // Keep the connection alive so the client can continue with 2FA. return true; } + if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await { + return keep_alive; + } if !self.connect_port_forward_if_needed().await { return false; } @@ -2376,33 +2413,6 @@ impl Connection { o.terminal_persistent.enum_value() == Ok(BoolOption::Yes); } self.terminal_service_id = terminal.service_id; - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if let Some(msg) = - self.fill_terminal_user_token(&lr.os_login.username, &lr.os_login.password) - { - self.send_login_error(msg).await; - sleep(1.).await; - return false; - } - - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if let Some(is_user) = - terminal_service::is_service_specified_user(&self.terminal_service_id) - { - if let Some(user_token) = &self.terminal_user_token { - let has_service_token = - user_token.to_terminal_service_token().is_some(); - if is_user != has_service_token { - // This occurs when the service id (in the configuration) is manually changed by the user, causing a mismatch in validation. - log::error!("Terminal service user mismatch detected. The service ID may have been manually changed in the configuration, causing validation to fail."); - // No need to translate the following message, because it is in an abnormal case. - self.send_login_error("Terminal service user mismatch detected.") - .await; - sleep(1.).await; - return false; - } - } - } } Some(login_request::Union::PortForward(mut pf)) => { if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) { @@ -2420,8 +2430,43 @@ impl Connection { } } + if !hbb_common::is_ip_str(&lr.username) + && !hbb_common::is_domain_port_str(&lr.username) + && lr.username != Config::get_id() + { + self.send_login_error(crate::client::LOGIN_MSG_OFFLINE) + .await; + return false; + } + + #[cfg(target_os = "windows")] + if self.terminal + && lr.os_login.username.trim().is_empty() + && crate::platform::is_prelogin() + { + self.send_login_error( + "No active console user logged on, please connect and logon first.", + ) + .await; + sleep(1.).await; + return false; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] - self.try_start_cm_ipc(); + if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + self.try_start_cm_ipc(); + } + + #[cfg(target_os = "linux")] + if should_check_linux_headless_os_auth_before_desktop_start( + self.linux_headless_handle.is_headless_allowed, + &lr.os_login.username, + ) { + let (_failure, res) = self.check_failure(0).await; + if !res { + return true; + } + } #[cfg(not(target_os = "linux"))] let err_msg = "".to_owned(); @@ -2433,6 +2478,18 @@ impl Connection { // If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password. if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY { + #[cfg(target_os = "linux")] + if should_record_linux_headless_os_auth_failure( + self.linux_headless_handle.is_headless_allowed, + &lr.os_login.username, + &err_msg, + ) { + let (failure, res) = self.check_failure(0).await; + if !res { + return true; + } + self.update_failure(failure, false, 0); + } self.send_login_error(err_msg).await; return true; } @@ -2461,17 +2518,16 @@ impl Connection { crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y" && is_logon(); - if !hbb_common::is_ip_str(&lr.username) - && !hbb_common::is_domain_port_str(&lr.username) - && lr.username != Config::get_id() - { - self.send_login_error(crate::client::LOGIN_MSG_OFFLINE) - .await; - return false; - } else if (password::approve_mode() == ApproveMode::Click - && !allow_logon_screen_password) + if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_password) || password::approve_mode() == ApproveMode::Both && !password::has_valid_password() { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await + { + return keep_alive; + } + } self.try_start_cm(lr.my_id, lr.my_name, false); if hbb_common::get_version_number(&lr.version) >= hbb_common::get_version_number("1.2.0") @@ -2493,6 +2549,14 @@ impl Connection { } } else if lr.password.is_empty() { if err_msg.is_empty() { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + if let Some(keep_alive) = + self.prepare_terminal_login_for_authorization().await + { + return keep_alive; + } + } self.try_start_cm(lr.my_id, lr.my_name, false); } else { self.send_login_error( @@ -2506,7 +2570,7 @@ impl Connection { return true; } if !self.validate_password(allow_logon_screen_password) { - self.update_failure(failure, false, 0); + self.update_failure_with_scope(failure, false, 0, FailureScope::Default); self.check_update_temporary_password(false); if err_msg.is_empty() { self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG) @@ -2519,7 +2583,7 @@ impl Connection { .await; } } else { - self.update_failure(failure, true, 0); + self.update_failure_with_scope(failure, true, 0, FailureScope::Default); if err_msg.is_empty() { #[cfg(target_os = "linux")] self.linux_headless_handle.wait_desktop_cm_ready().await; @@ -3484,16 +3548,16 @@ impl Connection { self.terminal_user_token = Some(TerminalUserToken::SelfUser); None } else { - Some("The user is not an administrator.") + Some(TERMINAL_OS_LOGIN_FAILED_MSG) } } Ok(Err(e)) => { log::error!("Failed to check if the user is an administrator: {}", e); - Some("Failed to check if the user is an administrator.") + Some(TERMINAL_OS_LOGIN_FAILED_MSG) } Err(e) => { log::error!("Failed to get logon user token: {}", e); - Some("Incorrect username or password.") + Some(TERMINAL_OS_LOGIN_FAILED_MSG) } } } @@ -3529,6 +3593,146 @@ impl Connection { } } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + async fn prepare_terminal_login_for_authorization(&mut self) -> Option { + if !self.terminal || self.terminal_user_token.is_some() { + return None; + } + + #[derive(Copy, Clone)] + enum TerminalAuthorizationMode { + OsLogin { + failure: ((i32, i32, i32), i32), + scope: FailureScope, + }, + SessionUser, + } + + let normalized_username = self.lr.os_login.username.trim().to_owned(); + let auth_mode = if should_use_terminal_os_login_scope(self.terminal, &normalized_username) { + // Check failure state + let failure_scope = FailureScope::TerminalOsLogin; + let (failure, res) = self.check_failure_with_scope(0, failure_scope).await; + if !res { + log::warn!( + "OS credential login blocked by failure policy: ip={} conn_id={} scope={:?}", + self.ip, + self.inner.id(), + failure_scope + ); + // Terminal OS login is sensitive. Close this connection instead of keeping it + // alive for retries on the same socket after a rate-limit block. + return Some(false); + } + TerminalAuthorizationMode::OsLogin { + failure, + scope: failure_scope, + } + } else { + TerminalAuthorizationMode::SessionUser + }; + + let is_terminal_os_login = matches!(auth_mode, TerminalAuthorizationMode::OsLogin { .. }); + let failure_scope = match auth_mode { + TerminalAuthorizationMode::OsLogin { scope, .. } => scope, + TerminalAuthorizationMode::SessionUser => FailureScope::Default, + }; + + let username = normalized_username; + let password = self.lr.os_login.password.clone(); + let terminal_login_error = { + #[cfg(target_os = "windows")] + { + // Concurrency gate for terminal OS login with credentials, to prevent brute-force attacks. + let _os_login_concurrency_guard = if is_terminal_os_login { + let guard = try_acquire_os_credential_login_gate(); + if guard.is_err() { + log::warn!( + "OS credential login blocked by concurrency gate: ip={} conn_id={} scope={:?}", + self.ip, + self.inner.id(), + failure_scope + ); + self.send_login_error("Please try 1 minute later").await; + sleep(1.).await; + Self::post_alarm_audit( + AlarmAuditType::TerminalOsLoginConcurrency, + json!({ + "ip": self.ip, + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + }), + ); + return Some(false); + } + guard.ok() + } else { + None + }; + self.fill_terminal_user_token(&username, &password) + } + #[cfg(not(target_os = "windows"))] + { + self.fill_terminal_user_token(&username, &password) + } + }; + if let Some(msg) = terminal_login_error { + if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode { + self.update_failure_with_scope(failure, false, 0, scope); + } + let auth_context = if is_terminal_os_login { + "OS credential login verification" + } else { + "Terminal session-user authorization" + }; + log::warn!( + "{} failed: ip={} conn_id={} scope={:?} msg='{}'", + auth_context, + self.ip, + self.inner.id(), + failure_scope, + msg + ); + self.send_login_error(msg).await; + sleep(1.).await; + return Some(false); + } + if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode { + self.update_failure_with_scope(failure, true, 0, scope); + } + + if let Some(is_user) = + terminal_service::is_service_specified_user(&self.terminal_service_id) + { + if let Some(user_token) = &self.terminal_user_token { + let has_service_token = user_token.to_terminal_service_token().is_some(); + if is_user != has_service_token { + log::error!( + "Terminal service user mismatch: ip={} conn_id={} service_is_user={} has_service_token={}. The service ID may have been manually changed in the configuration, causing validation to fail.", + self.ip, + self.inner.id(), + is_user, + has_service_token + ); + // No need to translate the following message, because it is in an abnormal case. + self.send_login_error("Terminal service user mismatch detected.") + .await; + sleep(1.).await; + return Some(false); + } + } + } + if is_terminal_os_login { + self.try_start_cm_ipc(); + } + None + } + + #[cfg(any(target_os = "android", target_os = "ios"))] + async fn prepare_terminal_login_for_authorization(&mut self) -> Option { + None + } + // Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes. // Parsing an IPv4 address just returns None. // note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues @@ -3555,18 +3759,37 @@ impl Connection { Some((p64, p56, p48)) } - fn update_failure(&self, (failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) { - fn bump(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) { - if cur.0 == time { - cur.1 += 1; - cur.2 += 1; - } else { - cur.0 = time; - cur.1 = 1; - cur.2 += 1; - } - cur + fn bump_failure_entry(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) { + if cur.0 == time { + cur.1 += 1; + cur.2 += 1; + } else { + cur.0 = time; + cur.1 = 1; + cur.2 += 1; } + cur + } + + fn update_failure(&self, failure: ((i32, i32, i32), i32), remove: bool, i: usize) { + self.update_failure_with_scope(failure, remove, i, FailureScope::Default); + } + + fn update_failure_with_scope( + &self, + (failure, time): ((i32, i32, i32), i32), + remove: bool, + i: usize, + scope: FailureScope, + ) { + let os_credential_scope = matches!(scope, FailureScope::TerminalOsLogin); + if os_credential_scope { + if !remove { + record_os_credential_failure(scope); + } + return; + } + let map_mutex = &LOGIN_FAILURES[i]; if remove { if failure.0 != 0 { @@ -3587,14 +3810,15 @@ impl Connection { let mut m = map_mutex.lock().unwrap(); for key in [p64, p56, p48] { let cur = m.get(&key).copied().unwrap_or((0, 0, 0)); - m.insert(key, bump(cur, time)); + m.insert(key, Self::bump_failure_entry(cur, time)); } - // Update full IP: bump from the *original* passed-in failure - m.insert(self.ip.clone(), bump(failure, time)); + let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0)); + m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time)); } else { - // Update full IP: bump from the *original* passed-in failure + // Re-read the full IP bucket in case another failed attempt updated it. let mut m = map_mutex.lock().unwrap(); - m.insert(self.ip.clone(), bump(failure, time)); + let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0)); + m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time)); } } @@ -3634,8 +3858,50 @@ impl Connection { } async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) { + self.check_failure_with_scope(i, FailureScope::Default) + .await + } + + async fn check_failure_with_scope( + &mut self, + i: usize, + scope: FailureScope, + ) -> (((i32, i32, i32), i32), bool) { let time = (get_time() / 60_000) as i32; + if matches!(scope, FailureScope::TerminalOsLogin) { + let decision = evaluate_os_credential_policy(scope, get_time()); + let res = if decision.allowed { + true + } else { + log::warn!( + "OS credential login blocked by policy: ip={} conn_id={} i={} msg='{}'", + self.ip, + self.inner.id(), + i, + decision.login_error.as_deref().unwrap_or("") + ); + if let Some(login_error) = decision.login_error { + // Rare branch and currently temporary response copy; translation can be added later if needed. + self.send_login_error(login_error).await; + } + if let Some(audit) = decision.audit { + // For OS blocked/backoff events, we currently emit one alarm report per blocked attempt. + // TODO: Add unified cumulative/aggregation fields across alarm producers. + Self::post_alarm_audit( + audit, + json!({ + "ip": self.ip, + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + }), + ); + } + false + }; + return (((0, 0, 0), time), res); + } + // IPv6 addresses are cheap to make so we check prefix/netblock as well if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await { @@ -5219,6 +5485,8 @@ pub enum AlarmAuditType { // MultipleLoginsAttemptsWithinOneMinute = 4, // MultipleLoginsAttemptsWithinOneHour = 5, ExceedIPv6PrefixAttempts = 6, + TerminalOsLoginBackoff = 7, + TerminalOsLoginConcurrency = 8, } pub enum FileAuditType { diff --git a/src/server/login_failure_check.rs b/src/server/login_failure_check.rs new file mode 100644 index 000000000..4394213ec --- /dev/null +++ b/src/server/login_failure_check.rs @@ -0,0 +1,231 @@ +use crate::AlarmAuditType; +use hbb_common::get_time; +#[cfg(target_os = "windows")] +use hbb_common::tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard}; +use std::sync::Mutex; +#[cfg(target_os = "windows")] +use std::sync::Arc; + +const OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS: i64 = 120 * 60 * 1_000; +const OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS: i64 = 15; +const OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS: i64 = 30 * 60; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) enum FailureScope { + Default, + TerminalOsLogin, +} + +pub(crate) struct OsCredentialPolicyDecision { + pub allowed: bool, + pub login_error: Option, + pub audit: Option, +} + +#[derive(Copy, Clone, Debug, Default)] +struct OsCredentialFailureState { + total_failures: i32, + backoff_until_ms: Option, + last_failure_ms: Option, +} + +lazy_static::lazy_static! { + static ref OS_CREDENTIAL_LOGIN_FAILURE_STATE: Mutex = + Mutex::new(OsCredentialFailureState::default()); +} + +#[cfg(target_os = "windows")] +lazy_static::lazy_static! { + static ref OS_CREDENTIAL_LOGIN_MUTEX: Arc> = Arc::new(TokioMutex::new(())); +} + +fn is_os_credential_scope(scope: FailureScope) -> bool { + matches!(scope, FailureScope::TerminalOsLogin) +} + +fn state_for_os_credential_scope( + scope: FailureScope, +) -> Option<&'static Mutex> { + if is_os_credential_scope(scope) { + Some(&OS_CREDENTIAL_LOGIN_FAILURE_STATE) + } else { + None + } +} + +fn backoff_audit_type_for_scope(scope: FailureScope) -> Option { + match scope { + FailureScope::TerminalOsLogin => Some(AlarmAuditType::TerminalOsLoginBackoff), + FailureScope::Default => None, + } +} + +fn os_credential_login_backoff_seconds(total_failures: i32) -> i64 { + if total_failures <= 2 { + return 0; + } + let exp = (total_failures - 3).min(7); + let seconds = OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS * (1_i64 << exp); + seconds.min(OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS) +} + +fn normalize_backoff(state: &mut OsCredentialFailureState, now_ms: i64) { + if let Some(until_ms) = state.backoff_until_ms { + if until_ms <= now_ms { + state.backoff_until_ms = None; + } + } +} + +fn reset_totals_on_idle(state: &mut OsCredentialFailureState, now_ms: i64) { + if let Some(last_ms) = state.last_failure_ms { + if now_ms.saturating_sub(last_ms) >= OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS { + state.total_failures = 0; + state.backoff_until_ms = None; + state.last_failure_ms = None; + } + } +} + +fn allow_decision() -> OsCredentialPolicyDecision { + OsCredentialPolicyDecision { + allowed: true, + login_error: None, + audit: None, + } +} + +fn block_decision( + login_error: String, + alarm_type: Option, +) -> OsCredentialPolicyDecision { + OsCredentialPolicyDecision { + allowed: false, + login_error: Some(login_error), + audit: alarm_type, + } +} + +pub(crate) fn evaluate_os_credential_policy( + scope: FailureScope, + now_ms: i64, +) -> OsCredentialPolicyDecision { + if !is_os_credential_scope(scope) { + return allow_decision(); + } + let Some(state_mutex) = state_for_os_credential_scope(scope) else { + return allow_decision(); + }; + let mut state = state_mutex.lock().unwrap(); + reset_totals_on_idle(&mut state, now_ms); + normalize_backoff(&mut state, now_ms); + + if let Some(until_ms) = state.backoff_until_ms { + let remaining_ms = (until_ms - now_ms).max(0); + let remaining_seconds = ((remaining_ms + 999) / 1_000).max(1); + let seconds_label = if remaining_seconds == 1 { + "second" + } else { + "seconds" + }; + block_decision( + format!( + "Please try again in {} {}.", + remaining_seconds, seconds_label + ), + backoff_audit_type_for_scope(scope), + ) + } else { + allow_decision() + } +} + +pub(crate) fn record_os_credential_failure(scope: FailureScope) { + if !is_os_credential_scope(scope) { + return; + } + let Some(state_mutex) = state_for_os_credential_scope(scope) else { + return; + }; + let mut state = state_mutex.lock().unwrap(); + let now_ms = get_time(); + reset_totals_on_idle(&mut state, now_ms); + normalize_backoff(&mut state, now_ms); + state.total_failures = state.total_failures.saturating_add(1); + state.last_failure_ms = Some(now_ms); + let backoff_seconds = os_credential_login_backoff_seconds(state.total_failures); + if backoff_seconds > 0 { + state.backoff_until_ms = Some(now_ms + backoff_seconds * 1_000); + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn try_acquire_os_credential_login_gate() -> Result, ()> { + OS_CREDENTIAL_LOGIN_MUTEX + .clone() + .try_lock_owned() + .map_err(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + static TEST_MUTEX: Mutex<()> = Mutex::new(()); + + fn clear_os_credential_failure_state(scope: FailureScope) { + if let Some(state_mutex) = state_for_os_credential_scope(scope) { + *state_mutex.lock().unwrap() = OsCredentialFailureState::default(); + } + } + + #[test] + fn os_credential_policy_prioritizes_backoff() { + let _guard = TEST_MUTEX.lock().unwrap(); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + let now_ms = get_time(); + for _ in 0..3 { + record_os_credential_failure(FailureScope::TerminalOsLogin); + } + let decision = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms); + assert!(!decision.allowed); + assert!(decision.login_error.is_some()); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + } + + #[test] + fn os_credential_policy_idle_window_resets_total_counter() { + let _guard = TEST_MUTEX.lock().unwrap(); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + for _ in 0..13 { + record_os_credential_failure(FailureScope::TerminalOsLogin); + } + let blocked = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, get_time()); + assert!(!blocked.allowed); + + let after_failures_ms = get_time(); + let after_idle_ms = after_failures_ms + OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS + 1_000; + let allowed = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, after_idle_ms); + assert!(allowed.allowed); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + } + + #[test] + fn os_credential_policy_audits_every_backoff_block() { + let _guard = TEST_MUTEX.lock().unwrap(); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + + for _ in 0..3 { + record_os_credential_failure(FailureScope::TerminalOsLogin); + } + let now_ms = get_time(); + let first = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms); + let second = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms + 1_000); + assert!(!first.allowed); + assert!(!second.allowed); + assert!(first.audit.is_some()); + assert!(second.audit.is_some()); + + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + } +} From 1978020d275f22ac2478232ccda691b63c7d90d0 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 11 May 2026 12:58:32 +0800 Subject: [PATCH 20/36] fix(custom-client): desktop, incoming only, touch drag (#14928) Signed-off-by: fufesou --- flutter/lib/desktop/widgets/tabbar_widget.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flutter/lib/desktop/widgets/tabbar_widget.dart b/flutter/lib/desktop/widgets/tabbar_widget.dart index ef195b493..9ef7d38d9 100644 --- a/flutter/lib/desktop/widgets/tabbar_widget.dart +++ b/flutter/lib/desktop/widgets/tabbar_widget.dart @@ -593,13 +593,13 @@ class _DesktopTabState extends State } Widget _buildBar() { + final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage(); return Row( children: [ Expanded( child: GestureDetector( // custom double tap handler - onTap: !(bind.isIncomingOnly() && isInHomePage()) && - showMaximize + onTap: !isIncomingHomePage && showMaximize ? () { final current = DateTime.now().millisecondsSinceEpoch; final elapsed = current - _lastClickTime; @@ -610,7 +610,7 @@ class _DesktopTabState extends State .then((value) => stateGlobal.setMaximized(value)); } } - : null, + : (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch. onPanStart: (_) => startDragging(isMainWindow), onPanCancel: () { // We want to disable dragging of the tab area in the tab bar. From d8808baa83347f4f8e3364fd4bd15e22891515e4 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Mon, 11 May 2026 12:58:49 +0800 Subject: [PATCH 21/36] Allow macOS monitor switching in privacy mode (#15004) Co-authored-by: Codex --- flutter/lib/common/widgets/toolbar.dart | 12 ++++++++++-- flutter/lib/desktop/widgets/remote_toolbar.dart | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 2e7247d95..537014246 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -16,6 +16,12 @@ import 'package:get/get.dart'; bool isEditOsPassword = false; +// macOS privacy mode blacks out all online displays, so switching the remote +// display does not weaken the local privacy protection. +bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) { + return pi.platform == kPeerPlatformMacOS; +} + class TTextMenu { final Widget child; final VoidCallback? onPressed; @@ -684,8 +690,9 @@ Future> toolbarDisplayToggle( child: Text(translate('Lock after session end')))); } + final privacyModeState = PrivacyModeState.find(id); if (pi.isSupportMultiDisplay && - PrivacyModeState.find(id).isEmpty && + (privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) && pi.displaysCount.value > 1 && bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') { final value = @@ -776,7 +783,8 @@ List toolbarPrivacyMode( onChanged: enabled ? (value) { if (value == null) return; - if (ffiModel.pi.currentDisplay != 0 && + if (!allowDisplaySwitchInPrivacyMode(pi) && + ffiModel.pi.currentDisplay != 0 && ffiModel.pi.currentDisplay != kAllDisplayValue) { msgBox( sessionId, diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 5da253e80..645cbe1cb 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -376,7 +376,8 @@ class _RemoteToolbarState extends State { } toolbarItems.add(Obx(() { - if (PrivacyModeState.find(widget.id).isEmpty && + if ((PrivacyModeState.find(widget.id).isEmpty || + allowDisplaySwitchInPrivacyMode(pi)) && pi.displaysCount.value > 1) { return _MonitorMenu( id: widget.id, From 55c9707639c40d78731cfff6ea7aaaddc6e8542a Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 12 May 2026 16:24:50 +0800 Subject: [PATCH 22/36] fix(msi): check install folder, remove files when uninstall (#15011) * fix(msi): check install folder, remove files when uninstall Signed-off-by: fufesou * fix(msi): harden install folder normalization cleanup Signed-off-by: fufesou * fix(msi): better file attributes Signed-off-by: fufesou * fix(mis): Simple refactor Signed-off-by: fufesou * fix(msi): avoid path-based attribute changes in cleanup Signed-off-by: fufesou * fix(msi): custom action, unset flag read before del Signed-off-by: fufesou --------- Signed-off-by: fufesou --- res/msi/CustomActions/CustomActions.cpp | 169 +++++++++----------- res/msi/CustomActions/CustomActions.def | 2 +- res/msi/Package/Components/Folders.wxs | 11 +- res/msi/Package/Components/RustDesk.wxs | 16 +- res/msi/Package/Fragments/CustomActions.wxs | 2 +- res/msi/Package/UI/MyInstallDlg.wxs | 16 +- 6 files changed, 109 insertions(+), 107 deletions(-) diff --git a/res/msi/CustomActions/CustomActions.cpp b/res/msi/CustomActions/CustomActions.cpp index 0107929f3..f4780dd87 100644 --- a/res/msi/CustomActions/CustomActions.cpp +++ b/res/msi/CustomActions/CustomActions.cpp @@ -31,17 +31,17 @@ LExit: return WcaFinalize(er); } -// Helper function to safely delete a file or directory using handle-based deletion. -// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions. +// Helper function to safely delete a file using handle-based deletion. +// Directories are refused after opening the handle. BOOL SafeDeleteItem(LPCWSTR fullPath) { - // Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT + // Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT // to prevent following symlinks. // Use shared access to allow deletion even when other processes have the file open. DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT; HANDLE hFile = CreateFileW( fullPath, - DELETE, + DELETE | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access NULL, OPEN_EXISTING, @@ -55,6 +55,21 @@ BOOL SafeDeleteItem(LPCWSTR fullPath) return FALSE; } + BY_HANDLE_FILE_INFORMATION fileInfo; + if (FALSE == GetFileInformationByHandle(hFile, &fileInfo)) + { + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to inspect '%ls'. Error: %lu", fullPath, GetLastError()); + CloseHandle(hFile); + return FALSE; + } + + if (fileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + { + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Refusing to delete directory '%ls'.", fullPath); + CloseHandle(hFile); + return FALSE; + } + // Use SetFileInformationByHandle to mark for deletion. // The file will be deleted when the handle is closed. FILE_DISPOSITION_INFO dispInfo; @@ -77,98 +92,74 @@ BOOL SafeDeleteItem(LPCWSTR fullPath) return result; } -// Helper function to recursively delete a directory's contents with detailed logging. -void RecursiveDelete(LPCWSTR path) +BOOL PathEndsWithSlash(LPCWSTR path) { - // Ensure the path is not empty or null. - if (path == NULL || path[0] == L'\0') + size_t length = 0; + HRESULT hr = StringCchLengthW(path, MAX_PATH, &length); + if (FAILED(hr) || length == 0) + { + return FALSE; + } + + WCHAR last = path[length - 1]; + return last == L'\\' || last == L'/'; +} + +void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes) +{ + if (!(attributes & FILE_ATTRIBUTE_READONLY)) { return; } - // Extra safety: never operate directly on a root path. - if (PathIsRootW(path)) + DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY; + if (writableAttributes == 0) { - WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path); + writableAttributes = FILE_ATTRIBUTE_NORMAL; + } + + if (SetFileAttributesW(fullPath, writableAttributes)) + { + WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath); return; } - // MAX_PATH is enough here since the installer should not be using longer paths. - // No need to handle extended-length paths (\\?\) in this context. - WCHAR searchPath[MAX_PATH]; - HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path); - if (FAILED(hr)) { - WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path); - return; + WcaLog(LOGMSG_STANDARD, "Runtime cleanup failed to clear read-only attribute for '%ls'. Error: %lu", fullPath, GetLastError()); +} + +BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName) +{ + WCHAR fullPath[MAX_PATH]; + LPCWSTR separator = PathEndsWithSlash(installFolder) ? L"" : L"\\"; + HRESULT hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s%s%s", installFolder, separator, fileName); + if (FAILED(hr)) + { + WcaLog(LOGMSG_STANDARD, "Runtime cleanup path is too long for '%ls'.", fileName); + return FALSE; } - WIN32_FIND_DATAW findData; - HANDLE hFind = FindFirstFileW(searchPath, &findData); - - if (hFind == INVALID_HANDLE_VALUE) + DWORD attributes = GetFileAttributesW(fullPath); + if (attributes == INVALID_FILE_ATTRIBUTES) { - // This can happen if the directory is empty or doesn't exist, which is not an error in our case. - WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError()); - return; + DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) + { + return TRUE; + } + + WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error); + return FALSE; } - do + if (attributes & FILE_ATTRIBUTE_DIRECTORY) { - // Skip '.' and '..' directories. - if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0) - { - continue; - } - - // MAX_PATH is enough here since the installer should not be using longer paths. - // No need to handle extended-length paths (\\?\) in this context. - WCHAR fullPath[MAX_PATH]; - hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName); - if (FAILED(hr)) { - WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path); - continue; - } - - // Before acting, ensure the read-only attribute is not set. - if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY) - { - if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY)) - { - WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError()); - } - } - - if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) - { - // Check for reparse points (symlinks/junctions) to prevent directory traversal attacks. - // Do not follow reparse points, only remove the link itself. - if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) - { - WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath); - SafeDeleteItem(fullPath); - } - else - { - // Recursively delete directory contents first - RecursiveDelete(fullPath); - // Then delete the directory itself - SafeDeleteItem(fullPath); - } - } - else - { - // Delete file using safe handle-based deletion - SafeDeleteItem(fullPath); - } - } while (FindNextFileW(hFind, &findData) != 0); - - DWORD lastError = GetLastError(); - if (lastError != ERROR_NO_MORE_FILES) - { - WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError); + WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath); + return FALSE; } - FindClose(hFind); + ClearReadOnlyAttribute(fullPath, attributes); + WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath); + return SafeDeleteItem(fullPath); } // See `Package.wxs` for the sequence of this custom action. @@ -178,13 +169,13 @@ void RecursiveDelete(LPCWSTR path) // 2. RemoveExistingProducts // ├─ TerminateProcesses // ├─ TryStopDeleteService -// ├─ RemoveInstallFolder - <-- Here +// ├─ RemoveRuntimeGeneratedFiles - <-- Here // └─ RemoveFiles // 3. InstallValidate // 4. InstallFiles // 5. InstallExecute // 6. InstallFinalize -UINT __stdcall RemoveInstallFolder( +UINT __stdcall RemoveRuntimeGeneratedFiles( __in MSIHANDLE hInstall) { HRESULT hr = S_OK; @@ -194,7 +185,7 @@ UINT __stdcall RemoveInstallFolder( LPWSTR pwz = NULL; LPWSTR pwzData = NULL; - hr = WcaInitialize(hInstall, "RemoveInstallFolder"); + hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles"); ExitOnFailure(hr, "Failed to initialize"); hr = WcaGetProperty(L"CustomActionData", &pwzData); @@ -202,24 +193,20 @@ UINT __stdcall RemoveInstallFolder( pwz = pwzData; hr = WcaReadStringFromCaData(&pwz, &installFolder); - ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz); + ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz); if (installFolder == NULL || installFolder[0] == L'\0') { - WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete."); + WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup."); goto LExit; } if (PathIsRootW(installFolder)) { - WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder); + WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder); goto LExit; } - WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder); - - RecursiveDelete(installFolder); - - // The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories. - // We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer. + WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder); + DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe"); LExit: ReleaseStr(pwzData); diff --git a/res/msi/CustomActions/CustomActions.def b/res/msi/CustomActions/CustomActions.def index 01b03490c..d50fbf59b 100644 --- a/res/msi/CustomActions/CustomActions.def +++ b/res/msi/CustomActions/CustomActions.def @@ -2,7 +2,7 @@ LIBRARY "CustomActions" EXPORTS CustomActionHello - RemoveInstallFolder + RemoveRuntimeGeneratedFiles TerminateProcesses AddFirewallRules SetPropertyIsServiceRunning diff --git a/res/msi/Package/Components/Folders.wxs b/res/msi/Package/Components/Folders.wxs index de9edb7f3..6911600e9 100644 --- a/res/msi/Package/Components/Folders.wxs +++ b/res/msi/Package/Components/Folders.wxs @@ -16,8 +16,15 @@ - - + + + + + + + + + diff --git a/res/msi/Package/Components/RustDesk.wxs b/res/msi/Package/Components/RustDesk.wxs index 337e84ec3..952172bdc 100644 --- a/res/msi/Package/Components/RustDesk.wxs +++ b/res/msi/Package/Components/RustDesk.wxs @@ -12,7 +12,7 @@ - + @@ -77,21 +77,21 @@ - - - + + + - + - + - + - + diff --git a/res/msi/Package/Fragments/CustomActions.wxs b/res/msi/Package/Fragments/CustomActions.wxs index 3727c0dd3..3a9811eb8 100644 --- a/res/msi/Package/Fragments/CustomActions.wxs +++ b/res/msi/Package/Fragments/CustomActions.wxs @@ -5,7 +5,7 @@ - + diff --git a/res/msi/Package/UI/MyInstallDlg.wxs b/res/msi/Package/UI/MyInstallDlg.wxs index bf59d569c..06c37097c 100644 --- a/res/msi/Package/UI/MyInstallDlg.wxs +++ b/res/msi/Package/UI/MyInstallDlg.wxs @@ -23,12 +23,13 @@ Patch dialog sequence: --> + - + @@ -64,9 +65,16 @@ Patch dialog sequence: - - - + + + + + + + + + + From b6caa1a7b2bb72c02f5b24fa7709eaf34e56daaf Mon Sep 17 00:00:00 2001 From: John Fowler Date: Wed, 13 May 2026 08:59:29 +0200 Subject: [PATCH 23/36] hu.rs update (#14983) Translate a new string. --- src/lang/hu.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 7f9b3299e..b4cbc1f23 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -743,6 +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", ""), + ("Enable privacy mode", "Adatvédelmi mód aktiválása"), ].iter().cloned().collect(); } From fe5a8cb2ad2d6b03a1e5bad42078c7153c582cef Mon Sep 17 00:00:00 2001 From: Alex Rijckaert Date: Wed, 13 May 2026 08:59:48 +0200 Subject: [PATCH 24/36] Update Dutch translation (#14984) --- src/lang/nl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 833c947cf..5a68d756d 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -743,6 +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", ""), + ("Enable privacy mode", "Schakel privacymodus in"), ].iter().cloned().collect(); } From dd265dadd79151d62f382a08110ce4db629bc862 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 13 May 2026 18:08:08 +0800 Subject: [PATCH 25/36] 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 42af0f0ae..c8cbb6be2 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 42af0f0aed0bb5fd5df4ff95fd4cc9816fcf5769 +Subproject commit c8cbb6be283e9215da87625016fe8838dda76c02 From 0d40cf2101a99ddae8edabf699eb71c50f41631a Mon Sep 17 00:00:00 2001 From: Alex Rijckaert Date: Thu, 14 May 2026 10:43:40 +0200 Subject: [PATCH 26/36] Update Dutch translations (#15024) Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> --- src/lang/nl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 5a68d756d..0f91d6a61 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -743,6 +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", "Schakel privacymodus in"), + ("Enable privacy mode", "Privacymodus inschakelen"), ].iter().cloned().collect(); } From 701a9c6cdc1df2210d8c3f954efa8545388d97b1 Mon Sep 17 00:00:00 2001 From: flusheDData <116861809+flusheDData@users.noreply.github.com> Date: Fri, 15 May 2026 09:31:25 +0200 Subject: [PATCH 27/36] New terms added (#15036) * Update es.rs New terms added * Update es.rs New terms added * Update Spanish translations for various strings * Fix typo in Spanish translation for TLS fallback * Add Spanish translations for various UI elements * Update es.rs --------- Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> --- src/lang/es.rs | 88 +++++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/src/lang/es.rs b/src/lang/es.rs index 2e543c25e..11c395f7d 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -74,7 +74,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Wrong Password", "Contraseña incorrecta"), ("Do you want to enter again?", "¿Quieres volver a entrar?"), ("Connection Error", "Error de conexión"), - ("Error", ""), + ("Error", ), ("Reset by the peer", "Restablecido por el par"), ("Connecting...", "Conectando..."), ("Connection in progress. Please wait.", "Conexión en curso. Espere por favor."), @@ -90,7 +90,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Receive", "Recibir"), ("Send", "Enviar"), ("Refresh File", "Actualizar archivo"), - ("Local", ""), + ("Local", ), ("Remote", "Remoto"), ("Remote Computer", "Computadora remota"), ("Local Computer", "Computadora local"), @@ -208,7 +208,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Closed manually by the peer", "Cerrado manualmente por el par"), ("Enable remote configuration modification", "Habilitar modificación remota de configuración"), ("Run without install", "Ejecutar sin instalar"), - ("Connect via relay", ""), + ("Connect via relay", "Conectar a través de relay"), ("Always connect via relay", "Conéctese siempre a través de relay"), ("whitelist_tip", "Solo las direcciones IP autorizadas pueden conectarse a este escritorio"), ("Login", "Iniciar sesión"), @@ -228,7 +228,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Username missed", "Olvidó su nombre de usuario"), ("Password missed", "Olvidó su contraseña"), ("Wrong credentials", "Credenciales incorrectas"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "El código de verificación es incorrecto o ha caducado"), ("Edit Tag", "Editar tag"), ("Forget Password", "Olvidar contraseña"), ("Favorites", "Favoritos"), @@ -302,8 +302,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keep RustDesk background service", "Dejar RustDesk como Servicio en 2do plano"), ("Ignore Battery Optimizations", "Ignorar optimizacioens de bateria"), ("android_open_battery_optimizations_tip", "Si deseas deshabilitar esta característica, por favor, ve a la página siguiente de ajustes, busca y entra en [Batería] y desmarca [Sin restricción]"), - ("Start on boot", ""), - ("Start the screen sharing service on boot, requires special permissions", ""), + ("Start on boot", "Iniciar al arrancar"), + ("Start the screen sharing service on boot, requires special permissions", "Iniciar el servicio de pantalla compartida al arrancar, requiere permisos especiales"), ("Connection not allowed", "Conexión no disponible"), ("Legacy mode", "Modo heredado"), ("Map mode", "Modo mapa"), @@ -326,8 +326,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Ratio", "Relación"), ("Image Quality", "Calidad de imagen"), ("Scroll Style", "Estilo de desplazamiento"), - ("Show Toolbar", ""), - ("Hide Toolbar", ""), + ("Show Toolbar", "Mostrar herramientas"), + ("Hide Toolbar", "Ocultar herramientas"), ("Direct Connection", "Conexión directa"), ("Relay Connection", "Conexión Relay"), ("Secure Connection", "Conexión segura"), @@ -338,7 +338,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Security", "Seguridad"), ("Theme", "Tema"), ("Dark Theme", "Tema Oscuro"), - ("Light Theme", ""), + ("Light Theme", "Tema claro"), ("Dark", "Oscuro"), ("Light", "Claro"), ("Follow System", "Tema del sistema"), @@ -355,12 +355,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Audio Input Device", "Dispositivo de entrada de audio"), ("Use IP Whitelisting", "Usar lista de IPs admitidas"), ("Network", "Red"), - ("Pin Toolbar", ""), - ("Unpin Toolbar", ""), + ("Pin Toolbar", "Anclar herramientas"), + ("Unpin Toolbar", "Desanclar herramientas"), ("Recording", "Grabando"), ("Directory", "Directorio"), ("Automatically record incoming sessions", "Grabación automática de sesiones entrantes"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Grabación automática de sesiones salientes"), ("Change", "Cambiar"), ("Start session recording", "Comenzar grabación de sesión"), ("Stop session recording", "Detener grabación de sesión"), @@ -368,7 +368,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable LAN discovery", "Habilitar descubrimiento de LAN"), ("Deny LAN discovery", "Denegar descubrimiento de LAN"), ("Write a message", "Escribir un mensaje"), - ("Prompt", ""), + ("Prompt", "Solicitud"), ("Please wait for confirmation of UAC...", "Por favor, espera confirmación de UAC"), ("elevated_foreground_window_tip", "La ventana actual del escritorio remoto necesita privilegios elevados para funcionar, así que no puedes usar ratón y teclado temporalmente. Puedes solicitar al usuario remoto que minimize la ventana actual o hacer clic en el botón de elevación de la ventana de gestión de conexión. Para evitar este problema, se recomienda instalar el programa en el dispositivo remto."), ("Disconnected", "Desconectado"), @@ -616,9 +616,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("During service is on", "Mientras el servicio está activo"), ("Capture screen using DirectX", "Capturar pantalla con DirectX"), ("Back", "Atrás"), - ("Apps", ""), - ("Volume up", "Bajar volumen"), - ("Volume down", "Subir volumen"), + ("Apps", "Aplicaciones"), + ("Volume up", "Subir volumen"), + ("Volume down", "Bajar volumen"), ("Power", "Encendido"), ("Telegram bot", "Bot de Telegram"), ("enable-bot-tip", "Si activas esta característica puedes recibir código 2FA de tu bot. También puede funcionar como notificación de conexión."), @@ -651,7 +651,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Update client clipboard", "Actualizar portapapeles del cliente"), ("Untagged", "Sin itiquetar"), ("new-version-of-{}-tip", "Hay una nueva versión de {} disponible"), - ("Accessible devices", ""), + ("Accessible devices", "Dispositivos accesibles"), ("upgrade_remote_rustdesk_client_to_{}_tip", "Por favor, actualiza el cliente RustDesk a la versión {} o superior en el lado remoto"), ("d3d_render_tip", "Al activar el renderizado D3D, la pantalla de control remoto puede verse negra en algunos equipos."), ("Use D3D rendering", "Usar renderizado D3D"), @@ -689,9 +689,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Use WebSocket", "Usar WebSocket"), ("Trackpad speed", "Velocidad de trackpad"), ("Default trackpad speed", "Velocidad predeterminada de trackpad"), - ("Numeric one-time password", ""), - ("Enable IPv6 P2P connection", ""), - ("Enable UDP hole punching", ""), + ("Numeric one-time password", "Contraseña numérica de un solo uso"), + ("Enable IPv6 P2P connection", "Habilitar conexión IPv6 P2P"), + ("Enable UDP hole punching", "Habilitar perforación de agujero UDP"), ("View camera", "Ver cámara"), ("Enable camera", "Habilitar cámara"), ("No cameras", "No hay cámaras"), @@ -708,8 +708,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Failed to check if the user is an administrator.", "No se ha podido comprobar si el usuario es un administrador."), ("Supported only in the installed version.", "Soportado solo en la versión instalada."), ("elevation_username_tip", "Introduzca el nombre de usuario o dominio\\NombreDeUsuario"), - ("Preparing for installation ...", ""), - ("Show my cursor", ""), + ("Preparing for installation ...", "Preparando instlación..."), + ("Show my cursor", "Mostrar mi cursor"), ("Scale custom", "Escala personalizada"), ("Custom scale slider", "Control deslizante de escala personalizada"), ("Decrease", "Disminuir"), @@ -721,28 +721,28 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show virtual joystick", "Mostrar joystick virtual"), ("Edit note", "Editar nota"), ("Alias", ""), - ("ScrollEdge", ""), - ("Allow insecure TLS fallback", ""), - ("allow-insecure-tls-fallback-tip", ""), - ("Disable UDP", ""), - ("disable-udp-tip", ""), - ("server-oss-not-support-tip", ""), - ("input note here", ""), - ("note-at-conn-end-tip", ""), - ("Show terminal extra keys", ""), - ("Relative mouse mode", ""), - ("rel-mouse-not-supported-peer-tip", ""), - ("rel-mouse-not-ready-tip", ""), - ("rel-mouse-lock-failed-tip", ""), - ("rel-mouse-exit-{}-tip", ""), - ("rel-mouse-permission-lost-tip", ""), - ("Changelog", ""), - ("keep-awake-during-outgoing-sessions-label", ""), - ("keep-awake-during-incoming-sessions-label", ""), + ("ScrollEdge", "Desplazamiento de pantalla"), + ("Allow insecure TLS fallback", "Permitir conexión TLS insegura de respaldo"), + ("allow-insecure-tls-fallback-tip", "De forma predeterminada, RustDesk verifica el certificado de servidor para protocolos que usen TLS.\nCon esta opción habilitada, Rustdesk volverá al paso de omisión de verificación y procederá en caso de fallo de verificación."), + ("Disable UDP", "Inhabilitar UDP"), + ("disable-udp-tip", "Controla si se usa TCP solamente.\nCuando esta opción está activa, RustDesk no usará más el puerto UDP 21116, en su lugar se usará el TCP 21116."), + ("server-oss-not-support-tip", "NOTA: El servidor RustDesk OSS no incluye esta característica."), + ("input note here", "Introducir nota aquí"), + ("note-at-conn-end-tip", "Pedir nota al finalizar la conexión"), + ("Show terminal extra keys", "Mostrar teclas extra del terminal"), + ("Relative mouse mode", "Modo de ratón relativo"), + ("rel-mouse-not-supported-peer-tip", "El modo relativo de ratón no está soportado por el par."), + ("rel-mouse-not-ready-tip", "El modo relativo de ratón aún no está preparado. Por favor, inténtalo de nuevo."), + ("rel-mouse-lock-failed-tip", "Ha fallado el bloqueo del cursor. El modo relativo del ratón ha sido inhabilitado."), + ("rel-mouse-exit-{}-tip", "Pulsa {} para salir."), + ("rel-mouse-permission-lost-tip", "Permiso de teclado revocado. El modo relativo del ratón ha sido inhabilitado."), + ("Changelog", "Registro de cambios"), + ("keep-awake-during-outgoing-sessions-label", "Mantener la pantalla activa durante sesiones salientes"), + ("keep-awake-during-incoming-sessions-label", "Mantener la pantalla activa durante sesiones entrantes"), ("Continue with {}", "Continuar con {}"), - ("Display Name", ""), - ("password-hidden-tip", ""), - ("preset-password-in-use-tip", ""), - ("Enable privacy mode", ""), + ("Display Name", "Nombre de pantalla"), + ("password-hidden-tip", "La contraseña permanente está ajustada a (oculta)."), + ("preset-password-in-use-tip", "Se está usando la contraseña predeterminada."), + ("Enable privacy mode", "Habilitar modo privado"), ].iter().cloned().collect(); } From 9f8f726f12da733527cffafd8fa9657c4784c2af Mon Sep 17 00:00:00 2001 From: rustdesk Date: Fri, 15 May 2026 17:30:59 +0800 Subject: [PATCH 28/36] fix compile --- src/lang/es.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lang/es.rs b/src/lang/es.rs index 11c395f7d..b822432a0 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -74,7 +74,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Wrong Password", "Contraseña incorrecta"), ("Do you want to enter again?", "¿Quieres volver a entrar?"), ("Connection Error", "Error de conexión"), - ("Error", ), + ("Error", ""), ("Reset by the peer", "Restablecido por el par"), ("Connecting...", "Conectando..."), ("Connection in progress. Please wait.", "Conexión en curso. Espere por favor."), @@ -90,7 +90,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Receive", "Recibir"), ("Send", "Enviar"), ("Refresh File", "Actualizar archivo"), - ("Local", ), + ("Local", ""), ("Remote", "Remoto"), ("Remote Computer", "Computadora remota"), ("Local Computer", "Computadora local"), From 472c4fc03ab3e7e160bfe71d46d5481c8946f9bb Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Sat, 16 May 2026 14:41:34 +0800 Subject: [PATCH 29/36] --deploy, reuse the device token (#15035) * --deploy, reuse the device token * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix review * no id validation in deploy, so to keep the same behavior in udp register pk * Fix collapsed toolbar drag preview sizing * Revert "Fix collapsed toolbar drag preview sizing" This reverts commit 66e39abb740b8cebbbf04e0441ce0c7433272d99. * remove too many logs --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/core_main.rs | 92 ++++++++++++++++++++++++++++++++++++++ src/ipc.rs | 12 +++++ src/rendezvous_mediator.rs | 59 ++++++++++++++++++++++++ 3 files changed, 163 insertions(+) diff --git a/src/core_main.rs b/src/core_main.rs index 67a83a37e..a0ca5eb95 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -627,6 +627,98 @@ pub fn core_main() -> Option> { println!("Installation and administrative privileges required!"); } return None; + } else if args[0] == "--deploy" { + if config::Config::no_register_device() { + println!("Cannot deploy an unregistrable device!"); + } else if crate::platform::is_installed() && is_root() { + let max = args.len() - 1; + let pos = args.iter().position(|x| x == "--token").unwrap_or(max); + if pos >= max { + println!("--token is required!"); + return None; + } + let token = args[pos + 1].to_owned(); + let get_value = |c: &str| { + let pos = args.iter().position(|x| x == c).unwrap_or(max); + if pos < max { + Some(args[pos + 1].to_owned()) + } else { + None + } + }; + let new_id = get_value("--id"); + let local_id = crate::ipc::get_id(); + let id_to_deploy = new_id.clone().unwrap_or_else(|| local_id.clone()); + let uuid = crate::encode64(hbb_common::get_uuid()); + let pk = crate::encode64( + hbb_common::config::Config::get_key_pair().1, + ); + let body = serde_json::json!({ + "id": id_to_deploy, + "uuid": uuid, + "pk": pk, + }); + let header = "Authorization: Bearer ".to_owned() + &token; + let url = crate::ui_interface::get_api_server() + "/api/devices/deploy"; + match crate::post_request_sync(url, body.to_string(), &header) { + Err(err) => { + println!("Request failed: {}", err); + std::process::exit(1); + } + Ok(text) => { + let parsed: serde_json::Value = + serde_json::from_str(&text).unwrap_or(serde_json::Value::Null); + let result = parsed["result"].as_str().unwrap_or(""); + match result { + "OK" => { + if let Some(ref new_id) = new_id { + if *new_id != local_id { + if let Err(err) = + crate::ipc::set_config("id", new_id.clone()) + { + println!( + "Failed to persist deployed id locally: {}", + err + ); + std::process::exit(1); + } + } + } + if let Err(err) = crate::ipc::notify_deployed() { + log::warn!("Failed to notify deployed state: {}", err); + } + println!("Device deployed."); + } + "NOT_ENABLED" => { + println!("Server does not require deployment."); + std::process::exit(3); + } + "INVALID_INPUT" => { + println!("Invalid input."); + std::process::exit(5); + } + "ID_TAKEN" => { + println!( + "Id `{}` is already used by another machine on the server.", + id_to_deploy + ); + std::process::exit(6); + } + _ => { + if text.is_empty() { + println!("Unknown response."); + } else { + println!("{}", text); + } + std::process::exit(1); + } + } + } + } + } else { + println!("Installation and administrative privileges required!"); + } + return None; } else if args[0] == "--check-hwcodec-config" { #[cfg(feature = "hwcodec")] crate::ipc::hwcodec_process(); diff --git a/src/ipc.rs b/src/ipc.rs index 0258a2816..0cd30634a 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -312,6 +312,7 @@ pub enum Data { ClipboardNonFile(Option<(String, Vec)>), PrivacyModeState((i32, PrivacyModeState, String)), TestRendezvousServer, + Deployed, #[cfg(not(any(target_os = "android", target_os = "ios")))] Keyboard(DataKeyboard), #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -929,6 +930,10 @@ async fn handle(data: Data, stream: &mut Connection) { Data::TestRendezvousServer => { crate::test_rendezvous_server(); } + Data::Deployed => { + crate::rendezvous_mediator::NEEDS_DEPLOY.store(false, Ordering::SeqCst); + crate::rendezvous_mediator::RendezvousMediator::restart(); + } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::SwitchSidesRequest(id) => { @@ -1737,6 +1742,13 @@ pub async fn test_rendezvous_server() -> ResultType<()> { Ok(()) } +#[tokio::main(flavor = "current_thread")] +pub async fn notify_deployed() -> ResultType<()> { + let mut c = connect(1000, "").await?; + c.send(&Data::Deployed).await?; + Ok(()) +} + #[tokio::main(flavor = "current_thread")] pub async fn send_url_scheme(url: String) -> ResultType<()> { connect(1_000, "_url") diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index 3ef280a2a..89d7fa01e 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -41,6 +41,30 @@ lazy_static::lazy_static! { static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false); static SENT_REGISTER_PK: AtomicBool = AtomicBool::new(false); +pub(crate) static NEEDS_DEPLOY: AtomicBool = AtomicBool::new(false); +// register_pk retry interval (ms) when device is awaiting deployment +const DEPLOY_RETRY_INTERVAL: i64 = 30_000; +lazy_static::lazy_static! { + static ref LAST_NOT_DEPLOYED_REGISTER: Mutex> = Mutex::new(None); +} + +// Single source of truth for the "awaiting deployment" backoff. The server has +// already told us this device is not in its db; until the operator runs +// `rustdesk --deploy --token ` there is no point re-running the +// register path more often than DEPLOY_RETRY_INTERVAL. Gating in the timer +// loops (rather than only inside register_pk) also avoids the +// last_register_sent / fails / latency / UDP-rebind churn the loop would +// otherwise spin on while no response ever comes back. +async fn deploy_register_throttled() -> bool { + if !NEEDS_DEPLOY.load(Ordering::SeqCst) { + return false; + } + LAST_NOT_DEPLOYED_REGISTER + .lock() + .await + .map(|t| (t.elapsed().as_millis() as i64) < DEPLOY_RETRY_INTERVAL) + .unwrap_or(false) +} #[derive(Clone)] pub struct RendezvousMediator { @@ -226,6 +250,14 @@ impl RendezvousMediator { if SHOULD_EXIT.load(Ordering::SeqCst) { break; } + // The server already told us this device is not deployed. Skip + // the whole register / fails / latency / UDP-rebind path until + // DEPLOY_RETRY_INTERVAL elapses, otherwise the loop spins every + // few seconds (log spam + misapplied network-recovery rebind) + // until the operator runs `rustdesk --deploy`. + if deploy_register_throttled().await { + continue; + } let now = Some(Instant::now()); let expired = last_register_resp.map(|x| x.elapsed().as_millis() as i64 >= REG_INTERVAL).unwrap_or(true); let timeout = last_register_sent.map(|x| x.elapsed().as_millis() as i64 >= reg_timeout).unwrap_or(false); @@ -289,10 +321,22 @@ impl RendezvousMediator { Config::set_key_confirmed(true); Config::set_host_key_confirmed(&self.host_prefix, true); *SOLVING_PK_MISMATCH.lock().await = "".to_owned(); + NEEDS_DEPLOY.store(false, Ordering::SeqCst); } Ok(register_pk_response::Result::UUID_MISMATCH) => { self.handle_uuid_mismatch(sink).await?; } + Ok(register_pk_response::Result::NOT_DEPLOYED) => { + if !NEEDS_DEPLOY.load(Ordering::SeqCst) { + log::warn!("Server requires deployment. Run `rustdesk --deploy --token ` on this device."); + } + NEEDS_DEPLOY.store(true, Ordering::SeqCst); + // Clear key_confirmed so the UI reflects the truth: this device is + // not currently registered. Covers the case where an online device + // was deleted by an admin while running. + Config::set_key_confirmed(false); + Config::set_host_key_confirmed(&self.host_prefix, false); + } _ => { log::error!("unknown RegisterPkResponse"); } @@ -678,6 +722,21 @@ impl RendezvousMediator { } async fn register_pk(&mut self, socket: Sink<'_>) -> ResultType<()> { + // Throttle register_pk when the device is awaiting deployment: server + // already told us we're not in its db; sending more often than every + // DEPLOY_RETRY_INTERVAL ms is wasted traffic until the operator runs + // `rustdesk --deploy --token `. + if NEEDS_DEPLOY.load(Ordering::SeqCst) { + let mut last = LAST_NOT_DEPLOYED_REGISTER.lock().await; + if let Some(t) = *last { + if (t.elapsed().as_millis() as i64) < DEPLOY_RETRY_INTERVAL { + return Ok(()); + } + } + *last = Some(Instant::now()); + } else { + *LAST_NOT_DEPLOYED_REGISTER.lock().await = None; + } let mut msg_out = Message::new(); let pk = Config::get_key_pair().1; let uuid = hbb_common::get_uuid(); From 377547fa1128823d6c5a4ea17b1310640264713b Mon Sep 17 00:00:00 2001 From: IronCodeStudios Date: Sun, 17 May 2026 16:02:23 +0800 Subject: [PATCH 30/36] scrap/wayland: insert videoconvert to fix screencast on COSMIC / DMA-BUF portals (#15063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Wayland compositors whose xdg-desktop-portal backend exposes screencast frames as DMA-BUF buffers — notably xdg-desktop-portal-cosmic 0.1.0 on Pop!_OS 24.04 / COSMIC — inbound screen capture fails. PipeWireRecorder links pipewiresrc directly to an appsink whose caps only accept video/x-raw BGRx/RGBx in system memory. That format set is too narrow for the portal's buffer-type / modifier negotiation, which collapses with: pw.link: negotiating -> error no more output formats (-22) gstpipewiresrc: stream error: no more output formats gstbasesrc: streaming stopped, reason not-negotiated (-4) ERROR src/server/wayland.rs: Failed scrap Element failed to change its state Inserting a videoconvert element between pipewiresrc and appsink widens the negotiable format set to any system-memory video/x-raw format, giving the portal room to settle on a format it can deliver via its SHM path. videoconvert then converts to the BGRx/RGBx the appsink expects. Verified on Pop!_OS 24.04 / COSMIC with gst-launch, before and after: # fails (current behaviour): gst-launch-1.0 pipewiresrc path=N ! video/x-raw,format=BGRx ! fakesink # works (with this change): gst-launch-1.0 pipewiresrc path=N ! videoconvert ! video/x-raw,format=BGRx ! fakesink After the change, inbound connections capture and stream the desktop normally and the "Failed scrap" error no longer occurs. Co-authored-by: Claude Opus 4.7 (1M context) --- libs/scrap/src/wayland/pipewire.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/libs/scrap/src/wayland/pipewire.rs b/libs/scrap/src/wayland/pipewire.rs index aedf786b7..8859d0d3b 100644 --- a/libs/scrap/src/wayland/pipewire.rs +++ b/libs/scrap/src/wayland/pipewire.rs @@ -276,12 +276,21 @@ impl PipeWireRecorder { // see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982 src.set_property("always-copy", &true)?; + // COSMIC/Wayland fix: insert videoconvert between pipewiresrc and appsink. + // xdg-desktop-portal-cosmic's modifier negotiation fails when the downstream + // format set is too narrow (appsink only accepts BGRx/RGBx), producing + // "no more output formats" / not-negotiated (-4). videoconvert accepts any + // system-memory video/x-raw format, widening negotiation so the portal can + // settle on a format it can deliver via its SHM path. + let convert = gst::ElementFactory::make("videoconvert", None)?; + let sink = gst::ElementFactory::make("appsink", None)?; sink.set_property("drop", &true)?; sink.set_property("max-buffers", &1u32)?; - pipeline.add_many(&[&src, &sink])?; - src.link(&sink)?; + pipeline.add_many(&[&src, &convert, &sink])?; + src.link(&convert)?; + convert.link(&sink)?; let appsink = sink .dynamic_cast::() From bc2c36215d15dd2ec223a5d470e38ebc87b9de7d Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 18 May 2026 16:32:46 +0800 Subject: [PATCH 31/36] fix(ipc): scope active-user IPC routing to root CLI main requests (#15058) * fix(ipc): scope active-user IPC routing to root CLI main requests Signed-off-by: fufesou * fix(ipc): cmdline, comments fails close Signed-off-by: fufesou * fix(ipc): cmdline, better check Signed-off-by: fufesou * fix(ipc): cmdline, try active uid when no --server processes Signed-off-by: fufesou * fix(ipc): cmdline, select active uid Signed-off-by: fufesou * fix(ipc): remove unused import Signed-off-by: fufesou --------- Signed-off-by: fufesou --- libs/hbb_common | 2 +- src/core_main.rs | 63 ++++++++++++ src/ipc.rs | 210 +++++++++++++++++++++++++++++++++++++--- src/ipc/auth.rs | 71 +++++++++++--- src/platform/windows.rs | 1 + 5 files changed, 317 insertions(+), 30 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index c8cbb6be2..9043c15ac 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit c8cbb6be283e9215da87625016fe8838dda76c02 +Subproject commit 9043c15acc6d5b42b6c12ad284c16c1ec172f1f0 diff --git a/src/core_main.rs b/src/core_main.rs index a0ca5eb95..ee2a9d90d 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -199,6 +199,20 @@ pub fn core_main() -> Option> { } std::thread::spawn(move || crate::start_server(false, no_server)); } else { + #[cfg(any(target_os = "linux", target_os = "macos"))] + // Root CLI management commands must talk to the user `--server` main IPC. + // Example: `sudo rustdesk --option custom-rendezvous-server` should query the + // user's IPC instead of root's `/tmp/-0/ipc`; `connect()` still limits this + // routing to empty-postfix main IPC only. + let _user_main_ipc_scope = if crate::platform::is_installed() + && is_root() + && is_user_main_ipc_scope_cli_command(&args) + { + Some(crate::ipc::UserMainIpcScope::new()) + } else { + None + }; + #[cfg(windows)] { use crate::platform; @@ -938,6 +952,55 @@ fn is_root() -> bool { crate::platform::is_root() } +#[cfg(any(target_os = "linux", target_os = "macos", test))] +fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool { + matches!( + args.first().map(String::as_str), + Some("--password") + | Some("--set-unlock-pin") + | Some("--get-id") + | Some("--set-id") + | Some("--config") + | Some("--option") + | Some("--assign") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn user_main_ipc_scope_cli_command_matches_management_commands_only() { + for command in [ + "--password", + "--set-unlock-pin", + "--get-id", + "--set-id", + "--config", + "--option", + "--assign", + ] { + assert!(is_user_main_ipc_scope_cli_command(&args(&[command]))); + } + + for command in [ + "--service", + "--server", + "--tray", + "--cm", + "--check-hwcodec-config", + "--connect", + ] { + assert!(!is_user_main_ipc_scope_cli_command(&args(&[command]))); + } + } +} + /// Check if the executable is a Quick Support version. /// Note: This function must be kept in sync with `libs/portable/src/main.rs`. #[cfg(windows)] diff --git a/src/ipc.rs b/src/ipc.rs index 0cd30634a..ffe1b08a5 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -33,25 +33,25 @@ use hbb_common::{ tokio_util::codec::Framed, ResultType, }; -#[cfg(any(target_os = "linux", target_os = "macos"))] -use ipc_auth::authorize_service_scoped_ipc_connection; #[cfg(windows)] pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection; #[cfg(windows)] pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; #[cfg(windows)] pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; -#[cfg(target_os = "linux")] -pub(crate) use ipc_auth::{ - active_uid, ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, - log_rejected_uinput_connection, peer_uid_from_fd, -}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; #[cfg(windows)] use ipc_auth::{ authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, should_allow_everyone_create_on_windows, }; #[cfg(target_os = "linux")] +pub(crate) use ipc_auth::{ + ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, + log_rejected_uinput_connection, peer_uid_from_fd, +}; +#[cfg(target_os = "linux")] use ipc_fs::terminal_count_candidate_uids; #[cfg(any(target_os = "linux", target_os = "macos"))] use ipc_fs::{ @@ -63,6 +63,8 @@ use parity_tokio_ipc::{ }; use serde_derive::{Deserialize, Serialize}; #[cfg(any(target_os = "linux", target_os = "macos"))] +use std::cell::Cell; +#[cfg(any(target_os = "linux", target_os = "macos"))] use std::os::unix::fs::PermissionsExt; use std::{ collections::HashMap, @@ -71,12 +73,47 @@ use std::{ // IPC actions here. pub const IPC_ACTION_CLOSE: &str = "close"; +#[cfg(target_os = "windows")] const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000; +#[cfg(target_os = "windows")] pub(crate) const IPC_TOKEN_LEN: usize = 64; +#[cfg(target_os = "windows")] const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2; +#[cfg(target_os = "windows")] const _: () = assert!(IPC_TOKEN_LEN % 2 == 0); pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true); +#[cfg(any(target_os = "linux", target_os = "macos"))] +thread_local! { + static USE_USER_MAIN_IPC: Cell = Cell::new(false); +} + +#[must_use = "bind this guard to a local variable to keep the IPC scope active"] +/// Thread-local guard for routing root main IPC to the active user on Linux/macOS. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) struct UserMainIpcScope { + previous: bool, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl UserMainIpcScope { + pub(crate) fn new() -> Self { + let previous = USE_USER_MAIN_IPC.with(|use_user_main| { + let previous = use_user_main.get(); + use_user_main.set(true); + previous + }); + Self { previous } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Drop for UserMainIpcScope { + fn drop(&mut self) { + USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.set(self.previous)); + } +} + #[inline] pub async fn connect_service(ms_timeout: u64) -> ResultType> { connect(ms_timeout, crate::POSTFIX_SERVICE).await @@ -1112,11 +1149,7 @@ async fn handle(data: Data, stream: &mut Connection) { }; } -pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { - let path = Config::ipc_path(postfix); - connect_with_path(ms_timeout, &path).await -} - +#[cfg(target_os = "windows")] pub(crate) fn generate_one_time_ipc_token() -> ResultType { use hbb_common::rand::{rngs::OsRng, RngCore as _}; use std::fmt::Write as _; @@ -1137,6 +1170,7 @@ pub(crate) fn generate_one_time_ipc_token() -> ResultType { Ok(token) } +#[cfg(target_os = "windows")] pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool { if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN { return false; @@ -1149,6 +1183,7 @@ pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> boo == 0 } +#[cfg(target_os = "windows")] pub(crate) async fn portable_service_ipc_handshake_as_client( stream: &mut ConnectionTmpl, token: &str, @@ -1173,6 +1208,7 @@ where } } +#[cfg(target_os = "windows")] pub(crate) async fn portable_service_ipc_handshake_as_server( stream: &mut ConnectionTmpl, mut validate_token: F, @@ -1209,6 +1245,103 @@ async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType, + prefer_root: bool, +) -> ResultType { + let mut server_uids = server_uids.to_vec(); + server_uids.sort_unstable(); + server_uids.dedup(); + + match server_uids.as_slice() { + [] => { + if let Some(uid) = active_uid { + // If no `--server` processes are found but the active user is identifiable, + // try the active user anyway because the main process may also listen on "" IPC. + return Ok(uid); + } else { + bail!("No --server process found for user main IPC") + } + } + [uid] => return Ok(*uid), + _ => {} + } + + if prefer_root && server_uids.contains(&0) { + return Ok(0); + } + if let Some(active_uid) = active_uid.filter(|uid| server_uids.contains(uid)) { + return Ok(active_uid); + } + bail!("Multiple --server processes found for user main IPC"); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn running_server_uids_for_current_exe() -> ResultType> { + let current_exe = std::env::current_exe()?; + let current_exe_path = std::fs::canonicalize(¤t_exe)?; + let current_pid = hbb_common::sysinfo::Pid::from_u32(std::process::id()); + let mut sys = hbb_common::sysinfo::System::new(); + sys.refresh_processes(); + let mut server_uids = Vec::new(); + for process in sys.processes().values() { + if process.pid() == current_pid { + continue; + } + if process.cmd().get(1).map_or(true, |arg| arg != "--server") { + continue; + } + let Ok(process_path) = std::fs::canonicalize(process.exe()) else { + continue; + }; + if process_path != current_exe_path { + continue; + } + let Some(uid) = process.user_id().map(|uid| **uid as u32) else { + // Root CLI management commands need a stable matching `--server` target. + // If this key process races during enumeration, failing the command is clearer + // than silently skipping it; `--server` is not expected to exit frequently. + bail!("Failed to read --server process uid"); + }; + server_uids.push(uid); + } + Ok(server_uids) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn user_main_ipc_server_uid() -> ResultType { + let server_uids = running_server_uids_for_current_exe()?; + #[cfg(target_os = "linux")] + let prefer_root = crate::platform::linux::is_login_screen_wayland(); + #[cfg(target_os = "macos")] + let prefer_root = false; + select_server_uid_for_user_main_ipc(&server_uids, active_uid(), prefer_root) +} + +pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let use_user_main_ipc = USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.get()); + let is_root_main_ipc = + unsafe { hbb_common::libc::geteuid() == 0 } && postfix.is_empty() && use_user_main_ipc; + if is_root_main_ipc { + let uid = user_main_ipc_server_uid()?; + let path = Config::ipc_path_for_uid(uid, postfix); + return connect_with_path(ms_timeout, &path).await; + } + let path = Config::ipc_path(postfix); + return connect_with_path(ms_timeout, &path).await; + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let path = Config::ipc_path(postfix); + connect_with_path(ms_timeout, &path).await + } +} + #[cfg(target_os = "linux")] pub async fn connect_for_uid( ms_timeout: u64, @@ -2002,7 +2135,16 @@ mod test { assert!(std::mem::size_of::() <= 120); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_service_ipc_path_is_shared_across_uids() { + assert_eq!( + Config::ipc_path_for_uid(0, crate::POSTFIX_SERVICE), + Config::ipc_path_for_uid(501, crate::POSTFIX_SERVICE) + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_ipc_path_differs_by_uid_for_cm() { let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; @@ -2021,4 +2163,46 @@ mod test { Config::ipc_path_for_uid(other_uid, postfix) ); } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_uses_active_uid_when_no_server_found() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[], Some(501), false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_uses_single_server_uid() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[501], None, false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_prefers_active_uid_with_multiple_servers() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[0, 501], Some(501), false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_prefers_root_on_wayland_login_screen() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[0, 501], Some(501), true).unwrap(), + 0 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_fails_when_multiple_servers_are_ambiguous() { + assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err()); + } } diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 746a32eed..77fd148c6 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -607,27 +607,30 @@ pub(crate) fn log_rejected_windows_ipc_connection( peer_session_id: Option, expected_session_id: Option, peer_is_system: Option, + peer_is_elevated: Option, ) { static LOG_THROTTLE: OnceLock> = OnceLock::new(); throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { if suppressed > 0 { log::warn!( - "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?} (suppressed {} similar events)", + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?} (suppressed {} similar events)", postfix, peer_pid, peer_session_id, expected_session_id, peer_is_system, + peer_is_elevated, suppressed ); } else { log::warn!( - "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}", + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}", postfix, peer_pid, peer_session_id, expected_session_id, - peer_is_system + peer_is_system, + peer_is_elevated ); } }); @@ -655,8 +658,14 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf #[cfg(windows)] pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { - let (authorized, peer_pid, peer_session_id, server_session_id, peer_is_system) = - stream.server_authorization_status(); + let ( + authorized, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + peer_is_elevated, + ) = stream.server_authorization_status(); if !authorized { log_rejected_windows_ipc_connection( postfix, @@ -664,6 +673,7 @@ pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix peer_session_id, server_session_id, peer_is_system, + peer_is_elevated, ); return false; } @@ -776,7 +786,14 @@ impl ConnectionTmpl { fn server_authorization_status( &self, - ) -> (bool, Option, Option, Option, Option) { + ) -> ( + bool, + Option, + Option, + Option, + Option, + Option, + ) { let peer_pid = self.peer_pid(); let server_session_id = crate::platform::windows::get_current_process_session_id(); let peer_session_id = @@ -786,20 +803,34 @@ impl ConnectionTmpl { let peer_is_system = peer_is_system_result .as_ref() .and_then(|r| r.as_ref().ok().copied()); - if server_session_id.is_none() && !peer_is_system.unwrap_or(false) { - // When the server session id cannot be determined, the session-id allow-path is - // disabled and only SYSTEM peers can be authorized. - log::debug!( - "IPC authorization: server session id unavailable; rejecting non-SYSTEM peer, peer_pid={:?}, peer_session_id={:?}", - peer_pid, - peer_session_id - ); - } - let authorized = is_allowed_windows_session_scoped_peer( + let session_authorized = is_allowed_windows_session_scoped_peer( peer_is_system.unwrap_or(false), peer_session_id, server_session_id, ); + let peer_is_elevated_result = if session_authorized { + None + } else { + peer_pid.map(|pid| crate::platform::windows::is_elevated(Some(pid))) + }; + let peer_is_elevated = peer_is_elevated_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + if server_session_id.is_none() + && !peer_is_system.unwrap_or(false) + && !peer_is_elevated.unwrap_or(false) + { + // When the server session id cannot be determined, the session-id allow-path is + // disabled and only privileged peers can be authorized. + log::debug!( + "IPC authorization: server session id unavailable; rejecting non-privileged peer, peer_pid={:?}, peer_session_id={:?}", + peer_pid, + peer_session_id + ); + } + // Main IPC trusts same-session peers, LocalSystem, and elevated administrators. + // Service-scoped IPC channels keep their own stricter authorization paths. + let authorized = session_authorized || peer_is_elevated.unwrap_or(false); if !authorized { if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { log::debug!( @@ -808,6 +839,13 @@ impl ConnectionTmpl { err ); } + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_elevated_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is elevated, pid={}, err={}", + pid, + err + ); + } } ( authorized, @@ -815,6 +853,7 @@ impl ConnectionTmpl { peer_session_id, server_session_id, peer_is_system, + peer_is_elevated, ) } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index a755714f9..1dc4a788a 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -614,6 +614,7 @@ fn authorize_service_scoped_ipc_connection( peer_session_id, expected_active_session_id, peer_is_system, + None, ); return false; } From 78e8134ad56094f58c53eaff2edae07a3845da55 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 18 May 2026 16:52:22 +0800 Subject: [PATCH 32/36] fix(ipc): cmdline, use scope, deploy (#15068) Signed-off-by: fufesou --- src/core_main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core_main.rs b/src/core_main.rs index ee2a9d90d..c9c1a658f 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -963,6 +963,7 @@ fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool { | Some("--config") | Some("--option") | Some("--assign") + | Some("--deploy") ) } From bb51c6aa4207b53904a2528615c5b94a37ecc053 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 18 May 2026 17:03:04 +0800 Subject: [PATCH 33/36] fix(ipc): cmdline, unit tests (#15069) Signed-off-by: fufesou --- src/core_main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core_main.rs b/src/core_main.rs index c9c1a658f..4515faa6b 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -985,6 +985,7 @@ mod tests { "--config", "--option", "--assign", + "--deploy", ] { assert!(is_user_main_ipc_scope_cli_command(&args(&[command]))); } From 546e9f1702572c4d5c9ce0d0f977cea17ba53c8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:07:43 +0800 Subject: [PATCH 34/36] Git submodule: Bump libs/hbb_common from `c8cbb6b` to `9043c15` (#15067) Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `c8cbb6b` to `9043c15`. - [Release notes](https://github.com/rustdesk/hbb_common/releases) - [Commits](https://github.com/rustdesk/hbb_common/compare/c8cbb6be283e9215da87625016fe8838dda76c02...9043c15acc6d5b42b6c12ad284c16c1ec172f1f0) --- updated-dependencies: - dependency-name: libs/hbb_common dependency-version: 9043c15acc6d5b42b6c12ad284c16c1ec172f1f0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From b81ae6c8949a50f64bac175f32217b8897078a2d Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Fri, 22 May 2026 07:36:15 -0300 Subject: [PATCH 35/36] Translate various labels to Portuguese-BR (#15086) Update --- src/lang/ptbr.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 4eb2c1544..36581d4f1 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -740,9 +740,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-outgoing-sessions-label", "Manter tela ativa durante sessões de saída"), ("keep-awake-during-incoming-sessions-label", "Manter tela ativa durante sessões de entrada"), ("Continue with {}", "Continuar com {}"), - ("Display Name", ""), - ("password-hidden-tip", ""), - ("preset-password-in-use-tip", ""), - ("Enable privacy mode", ""), + ("Display Name", "Nome de Exibição"), + ("password-hidden-tip", "A senha permanente está definida como (oculta)."), + ("preset-password-in-use-tip", "A senha predefinida está sendo usada."), + ("Enable privacy mode", "Habilitar modo de privacidade"), ].iter().cloned().collect(); } From 6ad56075d6d6b809f5699963bc417c48a138347c Mon Sep 17 00:00:00 2001 From: Luke <81411590+LukeCGG@users.noreply.github.com> Date: Sun, 24 May 2026 21:08:45 +1000 Subject: [PATCH 36/36] Drag whole toolbar; snap to all four edges of the remote session window (#15051) * Drag whole toolbar; snap to all four edges Today the drag handle on the remote-session toolbar repositions only the handle row -- the icons themselves stay centered at the top. This change applies the position to the entire toolbar wrapper so dragging the handle moves the whole thing, and extends snapping from top-only to any of the four window edges. When docked left/right the toolbar reflows vertically. A live ghost preview shows where the toolbar will land while you drag, with a small hysteresis bias to keep the preview from flickering near corners. The legacy 'remote-menubar-drag-x' session option is read as a fallback on first load so existing users keep their saved horizontal position; new option keys are 'remote-menubar-edge' and 'remote-menubar-frac'. Tested locally on Windows. macOS / Linux / web desktop use the same shared widget with no platform-specific calls, but I did not verify them. * Load edge independently and clamp loaded fraction Addresses CodeRabbit review on #15051: parse the saved edge regardless of whether the new fraction option is present so a partial write of frac doesn't reset the toolbar back to top, and clamp the loaded fraction to the kOptionRemoteMenubarDragLeft/Right contract so a corrupted or out-of-range saved value can't bypass the bounds until the user drags again. * Require edge activation zone to switch dock; preserve horizontal slide Per review feedback on #15051: nearest-edge-wins made a low-intent horizontal slide too easy to escalate into a high-impact orientation change (vertical reflow on left/right dock). The default drag now keeps the toolbar on its current dock edge and just updates the fraction along that edge -- the prior horizontal-slide behavior. An alternate edge is only previewed/committed when the cursor enters its 32 px activation zone; once previewed, the cursor has to move back 64 px before reverting (hysteresis at the zone boundary). * Gate multi-edge docking behind a settings toggle; default = horizontal slide Replaces the activation-zone approach with an explicit opt-in setting in Settings -> Other ("Allow docking remote toolbar to any window edge"). This addresses the concern that a low-intent horizontal drag shouldn't be able to trigger a high-impact orientation change, while still letting users who want multi-edge docking opt in cleanly. Default (toggle off): - The original horizontal slide is preserved. - The bug fix from the first commit still applies: dragging the handle moves the whole toolbar, and the position persists across collapse/expand (no more re-center on re-open). - Draggable is axis-locked to horizontal so the feedback widget stays on the top line during drag. Opt-in (toggle on): - Full nearest-edge wins with the live preview ghost and corner hysteresis; toolbar reflows vertically on left/right docks. - Draggable is unlocked for 2D drag. Reads the option via mainGetLocalBoolOptionSync so the toolbar's default state matches what the settings checkbox shows; the option key uses the allow- prefix so unset defaults to off. Takes effect on next session (setting is read at session init). The setting key (allow-multi-edge-toolbar-dock) is read by the existing local-options machinery and persists per-install without needing to be registered in libs/hbb_common's KEYS_LOCAL_SETTINGS. Can add that registration in a parallel hbb_common PR if preferred. * Fix remote toolbar drag positioning & persistence Align drag fraction calculation with the toolbar's actual travel range, keep preview sizing stable during drag, and preserve legacy horizontal position storage when multi-edge docking is disabled. Signed-off-by: fufesou * Remote toolbar snap edges 1. Translations 2. Apply option to remote windows on changed Signed-off-by: fufesou * fix: avoid remote toolbar docking jumps on setting reload Signed-off-by: fufesou * Fix remote toolbar docking updates and drag sync Signed-off-by: fufesou * refact: translation key Signed-off-by: fufesou * feat(toolbar-snap-edges): test web Signed-off-by: fufesou * Fix remote toolbar docking sync and vertical layout Signed-off-by: fufesou * Fix remote toolbar monitor controls on side docks Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: fufesou --- flutter/lib/consts.dart | 4 + .../desktop/pages/desktop_setting_page.dart | 10 + .../lib/desktop/widgets/remote_toolbar.dart | 841 +++++++++++++++--- flutter/lib/models/input_model.dart | 2 +- 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/en.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 | 2 + src/lang/he.rs | 1 + src/lang/hi.rs | 3 + 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/ml.rs | 3 + 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 | 3 +- 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 + 55 files changed, 802 insertions(+), 113 deletions(-) diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 832b96d24..adf7b1d45 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -142,6 +142,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse"; const String kOptionCodecPreference = "codec-preference"; const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left"; const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right"; +const String kOptionRemoteMenubarEdge = "remote-menubar-edge"; +const String kOptionRemoteMenubarFraction = "remote-menubar-frac"; +const String kOptionAllowMultiEdgeToolbarDock = + "allow-multi-edge-toolbar-dock"; const String kOptionHideAbTagsPanel = "hideAbTagsPanel"; const String kOptionRemoteMenubarState = "remoteMenubarState"; const String kOptionPeerSorting = "peer-sorting"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 2841c1d27..d1d620014 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -488,6 +488,16 @@ class _GeneralState extends State<_General> { _OptionCheckBox(context, 'Confirm before closing multiple tabs', kOptionEnableConfirmClosingTabs, isServer: false), + if (!bind.isIncomingOnly()) + _OptionCheckBox( + context, + 'allow-remote-toolbar-docking-any-edge', + kOptionAllowMultiEdgeToolbarDock, + isServer: false, + update: (_) { + reloadAllWindows(); + }, + ), _OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr), if (!isWeb) wallpaper(), if (!isWeb && !bind.isIncomingOnly()) ...[ diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 645cbe1cb..44a2dc1c7 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -28,6 +28,220 @@ import './kb_layout_type_chooser.dart'; import 'package:flutter_hbb/utils/scale.dart'; import 'package:flutter_hbb/common/widgets/custom_scale_base.dart'; +enum _ToolbarEdge { top, right, bottom, left } + +_ToolbarEdge _parseToolbarEdge(String? s) { + switch (s) { + case 'right': + return _ToolbarEdge.right; + case 'bottom': + return _ToolbarEdge.bottom; + case 'left': + return _ToolbarEdge.left; + default: + return _ToolbarEdge.top; + } +} + +String _toolbarEdgeToString(_ToolbarEdge e) { + switch (e) { + case _ToolbarEdge.top: + return 'top'; + case _ToolbarEdge.right: + return 'right'; + case _ToolbarEdge.bottom: + return 'bottom'; + case _ToolbarEdge.left: + return 'left'; + } +} + +bool _isHorizontalEdge(_ToolbarEdge e) => + e == _ToolbarEdge.top || e == _ToolbarEdge.bottom; + +const _legacyRemoteMenubarDragX = 'remote-menubar-drag-x'; + +double _clampToolbarFraction(double fraction, double left, double right) { + if (fraction < left) fraction = left; + if (fraction > right) fraction = right; + return fraction; +} + +Size _toolbarSizeForEdge(_ToolbarEdge edge, Size? measured) { + final isHorizontal = _isHorizontalEdge(edge); + final fallback = isHorizontal ? const Size(360, 40) : const Size(40, 360); + final size = measured ?? fallback; + final long = size.longestSide; + final short = size.shortestSide; + return Size(isHorizontal ? long : short, isHorizontal ? short : long); +} + +Offset _toolbarOffsetForEdge({ + required _ToolbarEdge edge, + required double fraction, + required Size parentSize, + required Size toolbarSize, +}) { + final xTravel = parentSize.width - toolbarSize.width; + final yTravel = parentSize.height - toolbarSize.height; + switch (edge) { + case _ToolbarEdge.top: + return Offset(xTravel * fraction, 0); + case _ToolbarEdge.bottom: + return Offset(xTravel * fraction, yTravel); + case _ToolbarEdge.left: + return Offset(0, yTravel * fraction); + case _ToolbarEdge.right: + return Offset(xTravel, yTravel * fraction); + } +} + +double _fractionForAlignedDrag({ + required double cursor, + required double grabOffset, + required double parentExtent, + required double toolbarExtent, + required double left, + required double right, +}) { + final travelExtent = parentExtent - toolbarExtent; + if (travelExtent <= 0) { + return _clampToolbarFraction(0.5, left, right); + } + return _clampToolbarFraction( + (cursor - grabOffset) / travelExtent, left, right); +} + +({double left, double right}) _fractionBoundsForEdge( + _ToolbarEdge edge, + double left, + double right, +) { + return _isHorizontalEdge(edge) + ? (left: left, right: right) + : (left: 0, right: 1); +} + +String _toolbarRawFraction({ + required bool multiEdgeEnabled, + required _ToolbarEdge edge, + required String? savedFraction, + required String? legacyFraction, +}) { + if (!multiEdgeEnabled) { + return (legacyFraction != null && legacyFraction.isNotEmpty) + ? legacyFraction + : '0.5'; + } + if (savedFraction != null && savedFraction.isNotEmpty) { + return savedFraction; + } + if (edge == _ToolbarEdge.top && + legacyFraction != null && + legacyFraction.isNotEmpty) { + return legacyFraction; + } + return '0.5'; +} + +// Returns the alignment for the wrapper Align that positions the entire +// toolbar against the given edge at the given fraction along that edge. +// Alignment uses [-1, 1] coordinates (0 = center). +Alignment _alignmentForEdge(_ToolbarEdge edge, double fraction) { + final f = fraction * 2 - 1; + switch (edge) { + case _ToolbarEdge.top: + return Alignment(f, -1); + case _ToolbarEdge.bottom: + return Alignment(f, 1); + case _ToolbarEdge.left: + return Alignment(-1, f); + case _ToolbarEdge.right: + return Alignment(1, f); + } +} + +// The drag handle hangs off the side of the toolbar facing away from the +// docked edge, so the icons themselves sit flush against that edge. +BorderRadius _collapseHandleBorderRadius(_ToolbarEdge edge) { + const r = Radius.circular(5); + switch (edge) { + case _ToolbarEdge.top: + return const BorderRadius.vertical(bottom: r); + case _ToolbarEdge.bottom: + return const BorderRadius.vertical(top: r); + case _ToolbarEdge.left: + return const BorderRadius.horizontal(right: r); + case _ToolbarEdge.right: + return const BorderRadius.horizontal(left: r); + } +} + +int _monitorMenuQuarterTurns(_ToolbarEdge edge) { + switch (edge) { + case _ToolbarEdge.left: + return 1; + case _ToolbarEdge.right: + return 3; + case _ToolbarEdge.top: + case _ToolbarEdge.bottom: + return 0; + } +} + +IconData _toolbarCollapseIcon(_ToolbarEdge edge, bool isCollapsed) { + switch (edge) { + case _ToolbarEdge.top: + return isCollapsed ? Icons.expand_more : Icons.expand_less; + case _ToolbarEdge.bottom: + return isCollapsed ? Icons.expand_less : Icons.expand_more; + case _ToolbarEdge.left: + return isCollapsed ? Icons.chevron_right : Icons.chevron_left; + case _ToolbarEdge.right: + return isCollapsed ? Icons.chevron_left : Icons.chevron_right; + } +} + +class _ToolbarDockingOptions { + _ToolbarDockingOptions({ + required this.edge, + required this.fraction, + required this.multiEdgeEnabled, + }); + + _ToolbarEdge edge; + double fraction; + bool multiEdgeEnabled; +} + +final _toolbarDockingOptionsBySession = {}; + +String _toolbarDockingCacheKey(SessionID sessionId) => sessionId.toString(); + +_ToolbarDockingOptions? _cachedToolbarDockingOptions(SessionID sessionId) => + _toolbarDockingOptionsBySession[_toolbarDockingCacheKey(sessionId)]; + +void _cacheToolbarDockingOptions({ + required SessionID sessionId, + required _ToolbarEdge edge, + required double fraction, + required bool multiEdgeEnabled, +}) { + final key = _toolbarDockingCacheKey(sessionId); + final cached = _toolbarDockingOptionsBySession[key]; + if (cached == null) { + _toolbarDockingOptionsBySession[key] = _ToolbarDockingOptions( + edge: edge, + fraction: fraction, + multiEdgeEnabled: multiEdgeEnabled, + ); + return; + } + cached.edge = edge; + cached.fraction = fraction; + cached.multiEdgeEnabled = multiEdgeEnabled; +} + class ToolbarState { late RxBool _pin; @@ -250,8 +464,26 @@ class RemoteToolbar extends StatefulWidget { class _RemoteToolbarState extends State { late Debouncer _debouncerHide; bool _isCursorOverImage = false; - final _fractionX = 0.5.obs; + final _fraction = 0.5.obs; + final _edge = _ToolbarEdge.top.obs; final _dragging = false.obs; + // Live drag preview: where the toolbar would dock if the user dropped now. + final _previewEdge = Rxn<_ToolbarEdge>(); + final _previewFraction = Rxn(); + // Measured size of the live toolbar, so the preview ghost matches reality + // (collapsed handle vs expanded toolbar). Updated after every layout pass. + final _toolbarSize = Rxn(); + final _toolbarKey = GlobalKey(debugLabel: 'remote_toolbar_root'); + // When false (default), the toolbar stays on the top edge and the drag + // handle just slides it horizontally — preserving long-standing UX while + // still fixing the bug where dragging only moved the handle. When true, + // the user has opted into multi-edge docking with nearest-edge snap. + // Kept in sync after settings-triggered rebuilds. + final _multiEdgeEnabled = false.obs; + final _dockingOptionsInitialized = false.obs; + bool _pendingDockingOptionSync = false; + int _dockingOptionSyncSerial = 0; + int _dragEpoch = 0; int get windowId => stateGlobal.windowId; @@ -273,16 +505,144 @@ class _RemoteToolbarState extends State { void _minimize() async => await WindowController.fromWindowId(windowId).minimize(); + Future _syncDockingOptions({required bool force}) async { + final syncSerial = ++_dockingOptionSyncSerial; + if (_dragging.isTrue) { + _deferDockingOptionsSync(); + return; + } + final dragEpoch = _dragEpoch; + + // Use the canonical helper so the option's documented default semantics + // apply (allow-* prefix => default false). Keeping it raw-string would + // diverge from how _OptionCheckBox displays the same key. + final multiEdgeEnabled = + mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock); + final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId); + if (cached == null && pi.isSet.isFalse) { + return; + } + final hadDockingOptions = cached != null; + final wasMultiEdgeEnabled = + cached?.multiEdgeEnabled ?? _multiEdgeEnabled.value; + if (!force && + hadDockingOptions && + wasMultiEdgeEnabled == multiEdgeEnabled) { + _pendingDockingOptionSync = false; + return; + } + + final savedFraction = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarFraction); + // Backward compat: legacy horizontal-only position. + final legacyFraction = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: _legacyRemoteMenubarDragX); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + + var nextEdge = _edge.value; + var savedFractionForNextEdge = savedFraction; + var keepCurrentPosition = false; + if (!multiEdgeEnabled) { + nextEdge = _ToolbarEdge.top; + } else if (force || wasMultiEdgeEnabled || cached == null) { + final edgeStr = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarEdge); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + nextEdge = _parseToolbarEdge(edgeStr); + } else { + // The setting changed from top-only to multi-edge while this toolbar is + // already visible. Keep its current position instead of jumping to the + // last saved multi-edge dock. + nextEdge = cached.edge; + savedFractionForNextEdge = cached.fraction.toString(); + keepCurrentPosition = true; + } + + final rawFraction = _toolbarRawFraction( + multiEdgeEnabled: multiEdgeEnabled, + edge: nextEdge, + savedFraction: savedFractionForNextEdge, + legacyFraction: legacyFraction, + ); + // Clamp to the saved drag-bound contract so a corrupted or out-of-range + // saved value can't bypass it until the user drags again. + final dragLeft = double.tryParse( + bind.mainGetLocalOption(key: kOptionRemoteMenubarDragLeft)) ?? + 0.0; + final dragRight = double.tryParse( + bind.mainGetLocalOption(key: kOptionRemoteMenubarDragRight)) ?? + 1.0; + final fractionBounds = + _fractionBoundsForEdge(nextEdge, dragLeft, dragRight); + final nextFraction = (double.tryParse(rawFraction) ?? 0.5) + .clamp(fractionBounds.left, fractionBounds.right) + .toDouble(); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + if (_dragging.isTrue || dragEpoch != _dragEpoch) { + _deferDockingOptionsSync(); + return; + } + _edge.value = nextEdge; + _fraction.value = nextFraction; + _multiEdgeEnabled.value = multiEdgeEnabled; + _dockingOptionsInitialized.value = true; + _cacheToolbarDockingOptions( + sessionId: widget.ffi.sessionId, + edge: nextEdge, + fraction: nextFraction, + multiEdgeEnabled: multiEdgeEnabled, + ); + _pendingDockingOptionSync = false; + if (!multiEdgeEnabled || keepCurrentPosition) { + bind.sessionPeerOption( + sessionId: widget.ffi.sessionId, + name: kOptionRemoteMenubarEdge, + value: _toolbarEdgeToString(nextEdge), + ); + bind.sessionPeerOption( + sessionId: widget.ffi.sessionId, + name: kOptionRemoteMenubarFraction, + value: nextFraction.toString(), + ); + } + } + + void _deferDockingOptionsSync() { + _pendingDockingOptionSync = true; + if (_dragging.isFalse) { + _syncDockingOptionsAfterDragIfNeeded(); + } + } + + void _markToolbarDragEpoch() { + ++_dragEpoch; + } + + void _syncDockingOptionsAfterDragIfNeeded() { + if (!_pendingDockingOptionSync) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _syncDockingOptions(force: false); + }); + } + @override initState() { super.initState(); + final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId); + final multiEdgeEnabled = + mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock); + final shouldResetToTop = + cached != null && cached.multiEdgeEnabled && !multiEdgeEnabled; + if (cached != null && !shouldResetToTop) { + _edge.value = cached.edge; + _fraction.value = cached.fraction; + _multiEdgeEnabled.value = multiEdgeEnabled; + _dockingOptionsInitialized.value = true; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { - _fractionX.value = double.tryParse(await bind.sessionGetOption( - sessionId: widget.ffi.sessionId, - arg: 'remote-menubar-drag-x') ?? - '0.5') ?? - 0.5; + await _syncDockingOptions(force: cached == null || shouldResetToTop); // Initialize toolbar states (collapse, hide) from session options widget.state.init(widget.ffi.sessionId); }); @@ -303,6 +663,14 @@ class _RemoteToolbarState extends State { }); } + @override + void didUpdateWidget(covariant RemoteToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _syncDockingOptions(force: false); + }); + } + _debouncerHideProc(int v) { if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) { collapse.value = true; @@ -311,64 +679,130 @@ class _RemoteToolbarState extends State { @override dispose() { - super.dispose(); - + ++_dockingOptionSyncSerial; widget.onEnterOrLeaveImageCleaner(identityHashCode(this)); + super.dispose(); } @override Widget build(BuildContext context) { return Obx(() { // Wait for initialization to complete to prevent flickering - if (!widget.state.initialized.value) { + if (!widget.state.initialized.value || + !_dockingOptionsInitialized.value) { return const SizedBox.shrink(); } // If toolbar is hidden, return empty widget if (hide.value) { return const SizedBox.shrink(); } - return Align( - alignment: Alignment.topCenter, - child: collapse.isFalse - ? _buildToolbar(context) - : _buildDraggableCollapse(context), + final edge = _edge.value; + final isHorizontal = _isHorizontalEdge(edge); + + // Measure the live toolbar after every layout so the preview ghost can + // match its actual footprint (collapsed handle vs expanded toolbar). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_dragging.isTrue) return; + final ro = _toolbarKey.currentContext?.findRenderObject(); + if (ro is RenderBox && ro.hasSize) { + final s = ro.size; + if (_toolbarSize.value != s) _toolbarSize.value = s; + } + }); + + final toolbar = Align( + alignment: _alignmentForEdge(edge, _fraction.value), + child: KeyedSubtree( + key: _toolbarKey, + child: collapse.isFalse + ? _buildToolbar(context, edge, isHorizontal) + : _buildDraggableCollapse(context, edge, isHorizontal), + ), + ); + + // Always return the Stack — even when not dragging — so the toolbar's + // position in the Element tree stays stable. Wrapping/unwrapping it + // mid-drag was killing the Draggable's gesture state. + return Stack( + fit: StackFit.expand, + children: [ + IgnorePointer( + child: Obx(() { + final pe = _previewEdge.value; + final pf = _previewFraction.value; + if (!_dragging.isTrue || pe == null || pf == null) { + return const SizedBox.shrink(); + } + return _buildDragPreview(context, pe, pf, _toolbarSize.value); + }), + ), + toolbar, + ], ); }); } - Widget _buildDraggableCollapse(BuildContext context) { + Widget _buildDragPreview(BuildContext context, _ToolbarEdge edge, + double fraction, Size? measured) { + final color = Theme.of(context).colorScheme.primary; + // Use the measured live toolbar size so collapsed vs expanded looks + // right. The current orientation may differ from the preview orientation + // (e.g. dragging a top-docked toolbar toward the left edge), so swap the + // long/short axes when previewing a different orientation. + final previewSize = _toolbarSizeForEdge(edge, measured); + return Align( + alignment: _alignmentForEdge(edge, fraction), + child: Container( + width: previewSize.width, + height: previewSize.height, + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.55), width: 1.5), + ), + ), + ); + } + + Widget _buildDraggableCollapse( + BuildContext context, _ToolbarEdge edge, bool isHorizontal) { return Obx(() { if (collapse.isFalse && _dragging.isFalse) { triggerAutoHide(); } - final borderRadius = BorderRadius.vertical( - bottom: Radius.circular(5), - ); - return Align( - alignment: FractionalOffset(_fractionX.value, 0), - child: Offstage( - offstage: _dragging.isTrue, - child: Material( - elevation: _ToolbarTheme.elevation, - shadowColor: MyTheme.color(context).shadow, + final borderRadius = _collapseHandleBorderRadius(edge); + return Offstage( + offstage: _dragging.isTrue, + child: Material( + elevation: _ToolbarTheme.elevation, + shadowColor: MyTheme.color(context).shadow, + borderRadius: borderRadius, + child: _DraggableShowHide( + id: widget.id, + sessionId: widget.ffi.sessionId, + dragging: _dragging, + fraction: _fraction, + edge: _edge, + previewEdge: _previewEdge, + previewFraction: _previewFraction, + toolbarSize: _toolbarSize, + markDragEpoch: _markToolbarDragEpoch, + syncDockingOptionsAfterDragIfNeeded: + _syncDockingOptionsAfterDragIfNeeded, + isHorizontal: isHorizontal, + multiEdgeEnabled: _multiEdgeEnabled.value, + toolbarState: widget.state, + setFullscreen: _setFullscreen, + setMinimize: _minimize, borderRadius: borderRadius, - child: _DraggableShowHide( - id: widget.id, - sessionId: widget.ffi.sessionId, - dragging: _dragging, - fractionX: _fractionX, - toolbarState: widget.state, - setFullscreen: _setFullscreen, - setMinimize: _minimize, - borderRadius: borderRadius, - ), ), ), ); }); } - Widget _buildToolbar(BuildContext context) { + Widget _buildToolbar( + BuildContext context, _ToolbarEdge edge, bool isHorizontal) { final List toolbarItems = []; toolbarItems.add(_PinMenu(state: widget.state)); if (!isWebDesktop) { @@ -382,6 +816,7 @@ class _RemoteToolbarState extends State { return _MonitorMenu( id: widget.id, ffi: widget.ffi, + edge: edge, setRemoteState: widget.setRemoteState); } else { return Offstage(); @@ -407,37 +842,53 @@ class _RemoteToolbarState extends State { if (!isWeb) toolbarItems.add(_RecordMenu()); toolbarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi)); final toolbarBorderRadius = BorderRadius.all(Radius.circular(4.0)); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Material( - elevation: _ToolbarTheme.elevation, - shadowColor: MyTheme.color(context).shadow, - borderRadius: toolbarBorderRadius, - color: Theme.of(context) - .menuBarTheme - .style - ?.backgroundColor - ?.resolve(MaterialState.values.toSet()), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Theme( - data: themeData(), - child: _ToolbarTheme.borderWrapper( - context, - Row( - children: [ - SizedBox(width: _ToolbarTheme.buttonHMargin * 2), - ...toolbarItems, - SizedBox(width: _ToolbarTheme.buttonHMargin * 2) - ], - ), - toolbarBorderRadius), - ), - ), + // innerAxis: how the toolbar icons themselves flow. + // outerAxis: how the toolbar block and the handle stack against each other + // (perpendicular to the dock edge, so the handle hangs off the interior face). + final innerAxis = isHorizontal ? Axis.horizontal : Axis.vertical; + final outerAxis = isHorizontal ? Axis.vertical : Axis.horizontal; + final spacer = isHorizontal + ? SizedBox(width: _ToolbarTheme.buttonHMargin * 2) + : SizedBox(height: _ToolbarTheme.buttonHMargin * 2); + final toolbarMaterial = Material( + elevation: _ToolbarTheme.elevation, + shadowColor: MyTheme.color(context).shadow, + borderRadius: toolbarBorderRadius, + color: Theme.of(context) + .menuBarTheme + .style + ?.backgroundColor + ?.resolve(MaterialState.values.toSet()), + child: SingleChildScrollView( + scrollDirection: innerAxis, + child: Theme( + data: themeData(), + child: _ToolbarTheme.borderWrapper( + context, + Flex( + direction: innerAxis, + mainAxisSize: MainAxisSize.min, + children: [ + spacer, + ...toolbarItems, + spacer, + ], + ), + toolbarBorderRadius), ), - _buildDraggableCollapse(context), - ], + ), + ); + final handle = _buildDraggableCollapse(context, edge, isHorizontal); + // The handle hangs off the interior face of the toolbar (away from the + // docked edge), centered along that face by the Flex's default cross-axis + // alignment, so the icons themselves sit flush against the docked edge. + final children = (edge == _ToolbarEdge.top || edge == _ToolbarEdge.left) + ? [toolbarMaterial, handle] + : [handle, toolbarMaterial]; + return Flex( + direction: outerAxis, + mainAxisSize: MainAxisSize.min, + children: children, ); } @@ -516,11 +967,13 @@ class _MobileActionMenu extends StatelessWidget { class _MonitorMenu extends StatelessWidget { final String id; final FFI ffi; + final _ToolbarEdge edge; final Function(VoidCallback) setRemoteState; const _MonitorMenu({ Key? key, required this.id, required this.ffi, + required this.edge, required this.setRemoteState, }) : super(key: key); @@ -531,9 +984,17 @@ class _MonitorMenu extends StatelessWidget { !isWeb && ffi.ffiModel.pi.isSupportMultiDisplay; @override - Widget build(BuildContext context) => showMonitorsToolbar - ? buildMultiMonitorMenu(context) - : Obx(() => buildMonitorMenu(context)); + Widget build(BuildContext context) { + final child = showMonitorsToolbar + ? buildMultiMonitorMenu(context) + : Obx(() => buildMonitorMenu(context)); + final quarterTurns = _monitorMenuQuarterTurns(edge); + if (quarterTurns == 0) return child; + return RotatedBox( + quarterTurns: quarterTurns, + child: child, + ); + } Widget buildMonitorMenu(BuildContext context) { final width = SimpleWrapper(0); @@ -665,7 +1126,8 @@ class _MonitorMenu extends StatelessWidget { } final scale = _ToolbarTheme.buttonSize / rect.height * 0.75; - final startY = (_ToolbarTheme.buttonSize - rect.height * scale) * 0.5; + final height = rect.height * scale; + final startY = (_ToolbarTheme.buttonSize - height) * 0.5; final startX = startY; final children = []; @@ -708,7 +1170,7 @@ class _MonitorMenu extends StatelessWidget { width.value = rect.width * scale + startX * 2; return SizedBox( width: width.value, - height: rect.height * scale + startY * 2, + height: height + startY * 2, child: Stack( children: children, ), @@ -2519,7 +2981,18 @@ class RdoMenuButton extends StatelessWidget { class _DraggableShowHide extends StatefulWidget { final String id; final SessionID sessionId; - final RxDouble fractionX; + final RxDouble fraction; + final Rx<_ToolbarEdge> edge; + final Rxn<_ToolbarEdge> previewEdge; + final Rxn previewFraction; + final Rxn toolbarSize; + final VoidCallback markDragEpoch; + final VoidCallback syncDockingOptionsAfterDragIfNeeded; + final bool isHorizontal; + // Whether multi-edge docking is enabled for this session (toggled in + // Settings -> Other). When false, the drag handle slides the toolbar + // horizontally on the top edge and never switches edges. + final bool multiEdgeEnabled; final RxBool dragging; final ToolbarState toolbarState; final BorderRadius borderRadius; @@ -2531,7 +3004,15 @@ class _DraggableShowHide extends StatefulWidget { Key? key, required this.id, required this.sessionId, - required this.fractionX, + required this.fraction, + required this.edge, + required this.previewEdge, + required this.previewFraction, + required this.toolbarSize, + required this.markDragEpoch, + required this.syncDockingOptionsAfterDragIfNeeded, + required this.isHorizontal, + required this.multiEdgeEnabled, required this.dragging, required this.toolbarState, required this.setFullscreen, @@ -2544,10 +3025,12 @@ class _DraggableShowHide extends StatefulWidget { } class _DraggableShowHideState extends State<_DraggableShowHide> { - Offset position = Offset.zero; - Size size = Size.zero; double left = 0.0; double right = 1.0; + Offset? _lastPointerDown; + Offset? _dragGrabOffset; + double? _dragLongAxisGrabOffset; + Size? _dragToolbarSize; RxBool get collapse => widget.toolbarState.collapse; @@ -2573,41 +3056,174 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { } } + // Bias applied to the currently-previewed edge so a drag hovering between + // two edges doesn't flicker. Only relevant when multi-edge is enabled. + static const double _switchHysteresisPx = 50.0; + + _ToolbarEdge _nearestToolbarEdge(Offset cursor, Size mediaSize) { + if (!widget.multiEdgeEnabled) return widget.edge.value; + + double rawDist(_ToolbarEdge e) { + switch (e) { + case _ToolbarEdge.top: + return cursor.dy; + case _ToolbarEdge.bottom: + return mediaSize.height - cursor.dy; + case _ToolbarEdge.left: + return cursor.dx; + case _ToolbarEdge.right: + return mediaSize.width - cursor.dx; + } + } + + final previewed = widget.previewEdge.value; + var winner = widget.edge.value; + var best = double.infinity; + for (final e in _ToolbarEdge.values) { + final biased = + e == previewed ? rawDist(e) - _switchHysteresisPx : rawDist(e); + if (biased < best) { + best = biased; + winner = e; + } + } + return winner; + } + + void _ensureDragGrabOffset(Offset cursor) { + if (_dragGrabOffset != null) return; + final mediaSize = MediaQueryData.fromView(View.of(context)).size; + final toolbarSize = + _toolbarSizeForEdge(widget.edge.value, widget.toolbarSize.value); + _dragToolbarSize = toolbarSize; + final toolbarOffset = _toolbarOffsetForEdge( + edge: widget.edge.value, + fraction: widget.fraction.value, + parentSize: mediaSize, + toolbarSize: toolbarSize, + ); + _dragGrabOffset = cursor - toolbarOffset; + _dragLongAxisGrabOffset = _isHorizontalEdge(widget.edge.value) + ? _dragGrabOffset?.dx + : _dragGrabOffset?.dy; + } + + double _dragGrabOffsetForEdge(_ToolbarEdge edge, Size toolbarSize) { + final offset = _dragLongAxisGrabOffset ?? 0; + final extent = + _isHorizontalEdge(edge) ? toolbarSize.width : toolbarSize.height; + return _clampToolbarFraction(offset, 0, extent); + } + + void _updatePreview(Offset cursor) { + _ensureDragGrabOffset(cursor); + final mediaSize = MediaQueryData.fromView(View.of(context)).size; + final winner = _nearestToolbarEdge(cursor, mediaSize); + widget.previewEdge.value = winner; + + final toolbarSize = _toolbarSizeForEdge(winner, _dragToolbarSize); + final grabOffset = _dragGrabOffsetForEdge(winner, toolbarSize); + final double frac; + if (winner == _ToolbarEdge.top || winner == _ToolbarEdge.bottom) { + frac = _fractionForAlignedDrag( + cursor: cursor.dx, + grabOffset: grabOffset, + parentExtent: mediaSize.width, + toolbarExtent: toolbarSize.width, + left: left, + right: right, + ); + } else { + final fractionBounds = _fractionBoundsForEdge(winner, left, right); + frac = _fractionForAlignedDrag( + cursor: cursor.dy, + grabOffset: grabOffset, + parentExtent: mediaSize.height, + toolbarExtent: toolbarSize.height, + left: fractionBounds.left, + right: fractionBounds.right, + ); + } + widget.previewFraction.value = frac; + } + + void _resetDragTracking() { + _lastPointerDown = null; + _dragGrabOffset = null; + _dragLongAxisGrabOffset = null; + _dragToolbarSize = null; + } + + void _commitPreview() { + final newEdge = widget.previewEdge.value; + final frac = widget.previewFraction.value; + widget.previewEdge.value = null; + widget.previewFraction.value = null; + widget.dragging.value = false; + widget.markDragEpoch(); + _resetDragTracking(); + widget.syncDockingOptionsAfterDragIfNeeded(); + if (newEdge == null || frac == null) return; + widget.edge.value = newEdge; + widget.fraction.value = frac; + _cacheToolbarDockingOptions( + sessionId: widget.sessionId, + edge: newEdge, + fraction: frac, + multiEdgeEnabled: widget.multiEdgeEnabled, + ); + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: kOptionRemoteMenubarEdge, + value: _toolbarEdgeToString(newEdge), + ); + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: kOptionRemoteMenubarFraction, + value: frac.toString(), + ); + if (widget.multiEdgeEnabled) { + return; + } + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: _legacyRemoteMenubarDragX, + value: frac.toString(), + ); + } + Widget _buildDraggable(BuildContext context) { - return Draggable( - axis: Axis.horizontal, - child: Icon( - Icons.drag_indicator, - size: 20, - color: MyTheme.color(context).drag_indicator, + return Listener( + onPointerDown: (event) => _lastPointerDown = event.position, + child: Draggable( + // When multi-edge docking is off the toolbar stays on the top edge, + // so lock the feedback to horizontal motion — otherwise the handle + // floats away from the top while dragging and the toolbar looks + // unmoored. When multi-edge is on we need 2D drag for snap-to-edge. + axis: widget.multiEdgeEnabled ? null : Axis.horizontal, + child: Icon( + widget.isHorizontal ? Icons.drag_indicator : Icons.drag_handle, + size: 20, + color: MyTheme.color(context).drag_indicator, + ), + feedback: widget, + onDragStarted: () { + widget.markDragEpoch(); + final pointerDown = _lastPointerDown; + if (pointerDown != null) { + _ensureDragGrabOffset(pointerDown); + } + widget.dragging.value = true; + // Seed the preview at the current docked edge/fraction so something + // shows the instant the drag begins, before the first onDragUpdate. + widget.previewEdge.value = widget.edge.value; + widget.previewFraction.value = widget.fraction.value; + }, + onDragUpdate: (details) { + _updatePreview(details.globalPosition); + }, + onDragEnd: (_) => _commitPreview(), ), - feedback: widget, - onDragStarted: (() { - final RenderObject? renderObj = context.findRenderObject(); - if (renderObj != null) { - final RenderBox renderBox = renderObj as RenderBox; - size = renderBox.size; - position = renderBox.localToGlobal(Offset.zero); - } - widget.dragging.value = true; - }), - onDragEnd: (details) { - final mediaSize = MediaQueryData.fromView(View.of(context)).size; - widget.fractionX.value += - (details.offset.dx - position.dx) / (mediaSize.width - size.width); - if (widget.fractionX.value < left) { - widget.fractionX.value = left; - } - if (widget.fractionX.value > right) { - widget.fractionX.value = right; - } - bind.sessionPeerOption( - sessionId: widget.sessionId, - name: 'remote-menubar-drag-x', - value: widget.fractionX.value.toString(), - ); - widget.dragging.value = false; - }, ); } @@ -2637,7 +3253,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { ); } - final child = Row( + final axis = widget.isHorizontal ? Axis.horizontal : Axis.vertical; + final child = Flex( + direction: axis, mainAxisSize: MainAxisSize.min, children: [ _buildDraggable(context), @@ -2678,7 +3296,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { message: translate( collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'), child: Icon( - collapse.isFalse ? Icons.expand_less : Icons.expand_more, + _toolbarCollapseIcon(widget.edge.value, collapse.isTrue), size: iconSize, ), ))), @@ -2720,7 +3338,8 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { borderRadius: widget.borderRadius, ), child: SizedBox( - height: 20, + height: widget.isHorizontal ? 20 : null, + width: widget.isHorizontal ? null : 20, child: child, ), ), 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/src/lang/ar.rs b/src/lang/ar.rs index 4113c1391..e13404802 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "كلمة المرور مخفية"), ("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 1a3260c5a..9f6b69c8b 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Зададзены пастаянны пароль (скрыты)."), ("preset-password-in-use-tip", "Пададзены пароль цяпер выкарыстоўваецца"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 17a89ce07..0aa61b1eb 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 799ca951f..2f706cc89 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 1ff10c49d..a90e5e194 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "永久密码已设置(已隐藏)"), ("preset-password-in-use-tip", "当前使用预设密码"), ("Enable privacy mode", "允许隐私模式"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 2b9c6219e..7f50d826f 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 7410124df..c9d3b4eb0 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 030bc626d..e6233e91e 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."), ("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."), ("Enable privacy mode", "Datenschutzmodus aktivieren"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 0633889a7..d03bb069c 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 73974a2e5..595169b8a 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -274,5 +274,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"), ("password-hidden-tip", "Permanent password is set (hidden)."), ("preset-password-in-use-tip", "Preset password is currently in use."), + ("allow-remote-toolbar-docking-any-edge", "Allow docking remote toolbar to any window edge"), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 16d43c9b4..131a85fbf 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index b822432a0..5e73b58a8 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "La contraseña permanente está ajustada a (oculta)."), ("preset-password-in-use-tip", "Se está usando la contraseña predeterminada."), ("Enable privacy mode", "Habilitar modo privado"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index a00c312b8..76abc8563 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index aaf8a8be8..9e19d1fea 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index d34e4239e..9e01b7eb0 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 1bddd39d1..f8283685b 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 6f7bb2880..f21d9b0df 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."), ("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."), ("Enable privacy mode", "Activer le mode de confidentialité"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index fba2fd83d..2fc8f282d 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 8b8568c85..ac0a588a8 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -654,6 +654,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Accessible devices", "એક્સેસિબલ ઉપકરણો"), ("upgrade_remote_rustdesk_client_to_{}_tip", "રિમોટ ક્લાયન્ટને {} માં અપગ્રેડ કરો"), ("d3d_render_tip", "D3D રેન્ડરિંગ વાપરો"), + ("Use D3D rendering", ""), ("Printer", "પ્રિન્ટર"), ("printer-os-requirement-tip", "પ્રિન્ટિંગ માટે Windows જરૂરી છે."), ("printer-requires-installed-{}-client-tip", "આ માટે {} ક્લાયન્ટ ઇન્સ્ટોલ હોવું જોઈએ."), @@ -743,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "સુરક્ષા માટે પાસવર્ડ છુપાવેલ છે."), ("preset-password-in-use-tip", "પ્રીસેટ પાસવર્ડ વપરાશમાં છે."), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 682ee0c46..44b940784 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index d35095fd1..904d43118 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -654,6 +654,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Accessible devices", "सुलभ डिवाइस"), ("upgrade_remote_rustdesk_client_to_{}_tip", "रिमोट RustDesk क्लाइंट को संस्करण {} में अपग्रेड करें"), ("d3d_render_tip", "D3D रेंडरिंग का उपयोग करें"), + ("Use D3D rendering", ""), ("Printer", "प्रिंटर"), ("printer-os-requirement-tip", "प्रिंटिंग के लिए Windows आवश्यक है।"), ("printer-requires-installed-{}-client-tip", "इसके लिए क्लाइंट साइड पर {} इंस्टॉल होना चाहिए।"), @@ -742,5 +743,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "प्रदर्शित नाम"), ("password-hidden-tip", "पासवर्ड सुरक्षा के लिए छिपा हुआ है।"), ("preset-password-in-use-tip", "पूर्व-निर्धारित पासवर्ड उपयोग में है।"), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 505b01df9..0593ff6b7 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index b4cbc1f23..3eb16890f 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", "Adatvédelmi mód aktiválása"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index bbd95e79a..bcda0a3a8 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 479551fcc..a5132e027 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "È impostata una password permanente (nascosta)."), ("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."), ("Enable privacy mode", "Abilita modalità privacy"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index b55a6664f..2879e86bf 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"), ("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index de68574e1..350d570b0 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."), ("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."), ("Enable privacy mode", "개인정보 보호 모드 사용함"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index a2a1624f7..4476fadc7 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 82422c30a..47ace51ae 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 906d056bd..4f8e1f59f 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 099f1d385..4dcfe9e74 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -654,6 +654,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Accessible devices", "ലഭ്യമായ ഉപകരണങ്ങൾ"), ("upgrade_remote_rustdesk_client_to_{}_tip", "റിമോട്ട് പതിപ്പ് {} ലേക്ക് മാറ്റുക"), ("d3d_render_tip", "D3D റെൻഡറിംഗ് ഉപയോഗിക്കുക"), + ("Use D3D rendering", ""), ("Printer", "പ്രിന്റർ"), ("printer-os-requirement-tip", "പ്രിന്റിംഗിന് വിൻഡോസ് വേണം."), ("printer-requires-installed-{}-client-tip", "ഇതിന് {} ക്ലയന്റ് ഇൻസ്റ്റാൾ ചെയ്യണം."), @@ -742,5 +743,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "ഡിസ്‌പ്ലേ പേര്"), ("password-hidden-tip", "സുരക്ഷയ്ക്കായി പാസ്‌വേഡ് മറച്ചിരിക്കുന്നു."), ("preset-password-in-use-tip", "പ്രീസെറ്റ് പാസ്‌വേഡ് ഉപയോഗത്തിലാണ്."), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 5795b9eeb..9325dfa1f 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 0f91d6a61..55d272666 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", "Privacymodus inschakelen"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 972afc170..fdf4ae8c5 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Ustawiono (ukryto) stare hasło."), ("preset-password-in-use-tip", "Obecnie używane jest hasło domyślne."), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 899c8da71..4138b46e4 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 36581d4f1..1428a71d0 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "A senha permanente está definida como (oculta)."), ("preset-password-in-use-tip", "A senha predefinida está sendo usada."), ("Enable privacy mode", "Habilitar modo de privacidade"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 45b22684e..bde4a4201 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -540,7 +540,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("auto_disconnect_option_tip", "Deconectează automat sesiunile de la distanță după o perioadă de inactivitate."), ("Connection failed due to inactivity", "Conexiunea a eșuat din cauza inactivității"), ("Check for software update on startup", "Verifică actualizări la pornire"), - ("upgrade_rustdesk_server_pro_{}_tip", "Versiunea serverului RustDesk Pro este mai mică decât {}. Te rugăm să o actualizezi."), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Versiunea serverului RustDesk Pro este mai mică decât {}. Te rugăm să o actualizezi."), ("pull_group_failed_tip", "Sincronizarea grupului a eșuat. Verifică conexiunea la rețea sau autentifică-te din nou."), ("Filter by intersection", "Filtrează prin intersecție"), ("Remove wallpaper during incoming sessions", "Elimină imaginea de fundal în timpul sesiunilor primite"), @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 3917c6fa2..2605582f4 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Установлен постоянный пароль (скрытый)."), ("preset-password-in-use-tip", "Установленный пароль сейчас используется."), ("Enable privacy mode", "Использовать режим конфиденциальности"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 68ce541f2..06919b752 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 6b4e16688..963f48728 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 3f35dea88..0f85af0c3 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index f7f6c16d4..7c965cd45 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index bedbe4856..fc33e4671 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index eda7851c1..664dc4745 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 6e5652560..93aeb6462 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 5e25801d2..33b359c5e 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index c2d058c98..a24c60bf6 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index d93ad4f68..c28086cc9 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Parola gizli"), ("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"), ("Enable privacy mode", "Gizlilik modunu etkinleştir"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index b23b84949..6df025303 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "固定密碼已設定(已隱藏)"), ("preset-password-in-use-tip", "目前正在使用預設密碼"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 3e1c4f25e..7107bc261 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 3fadb0efc..0910025ed 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); }