Compare commits

..

4 Commits

Author SHA1 Message Date
fufesou
d403d640f8 fix(keyboard): shortcuts, harden config and callback lifecycle
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-05 21:34:22 +08:00
rustdesk
42a88ac1f0 langs 2026-04-30 16:56:19 +08:00
rustdesk
cd7686baa2 feat(shortcuts): user-configurable keyboard shortcuts for session actions
Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
  parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
  session actions. Bindings are stored in LocalConfig under
  `keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
  `pass_through` flags so flipping the master switch off is a hard stop.

  Wire-up summary:
  - src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
    flutter/test/fixtures/default_keyboard_shortcuts.json
  - src/keyboard.rs: shortcut intercept in process_event{,_with_session},
    feature-gated to `flutter`; runs before key swapping so users bind to
    physical keys
  - src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
    main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
  - flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
    recording dialog, shortcut display formatter, action group registry
  - flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
    flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
    shells around the shared body
  - flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
    registerSessionShortcutActions for actions with no toolbar TToggleMenu /
    TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
  - flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
    TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
    tagged entries' existing onChanged into the ShortcutModel
  - flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
    parity (default bindings, supported key vocabulary)

  Design principles applied during review:

  1. Additions are fine; modifications to original logic must be deliberate.
     Tagging an existing TToggleMenu entry with `actionId:` is an addition.
     Rewriting its onChanged to satisfy a new contract is a modification —
     and was reverted for every case where the original click behavior was
     working. Four closures were touched and then reverted (mobile View
     Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
     wheel); their shortcuts are wired via standalone closures in
     shortcut_model.dart instead.

  2. Toolbar auto-register is reserved for entries whose onChanged is
     inherently self-flipping — typically `sessionToggleOption(name)` where
     the named option is flipped in place and the input bool is unused. The
     register pass passes `!menu.value` from registration time, which is
     harmless under self-flipping but wrong for closures that consume the
     input bool directly. Tagging a non-self-flipping entry forces a closure
     rewrite; choose non-toolbar registration in that case.

  3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
     unchanged. The matcher's `enabled`-gate already guarantees no
     dispatch; the auto-register pass is left unconditional (its only effect
     is HashMap operations on a separate ShortcutModel) so mid-session
     enable works without a reconnect. The trade-off is intentional and
     documented at the top of toolbarControls.

  4. Comments stay terse. Rationale lives in one place — the doc comment of
     the helper or registration site, not duplicated at every call site.

  5. Where an existing helper needs a new optional behavior (e.g.
     `_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
     to byte-identical output for existing callers (`trailing == null`
     case → original `Expanded(Text)` layout). Verified.

  6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
     `reset_canvas` so the action ID matches its user-facing label
     ("Reset canvas") and capability flag.

  Out-of-scope but included:
  - AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
    Web target's hand-written TS client, since both are load-bearing for
    any new FFI work.
  - remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
    monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
2026-04-30 16:40:42 +08:00
rustdesk
68e07ed7eb fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848) 2026-04-29 17:39:08 +08:00
94 changed files with 5802 additions and 488 deletions

4
.gitignore vendored
View File

@@ -55,4 +55,6 @@ examples/**/target/
vcpkg_installed
flutter/lib/generated_plugin_registrant.dart
libsciter.dylib
flutter/web/
flutter/web/
# Local git worktrees
.worktrees/

View File

@@ -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<T>` / `Future<T>` 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.

View File

@@ -0,0 +1,111 @@
// flutter/lib/common/widgets/keyboard_shortcuts/display.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../../consts.dart';
import '../../../models/platform_model.dart';
import 'shortcut_utils.dart';
/// Read the bindings JSON and produce a human-readable shortcut string for
/// `actionId`, formatted for the current OS. Returns null if unbound, or —
/// when [requireEnabled] is true (the default) — when the master toggle is
/// off. The configuration page passes `requireEnabled: false` so users still
/// see what they have bound while the feature is disabled.
class ShortcutDisplay {
// Cache parsed JSON keyed by the raw string — called per visible action on
// every menu rebuild, so the jsonDecode is the real cost. Invalidation is
// automatic: a write changes the raw and we re-parse.
static String? _cachedRaw;
static Map<String, dynamic>? _cachedParsed;
@visibleForTesting
static void resetCache() {
_cachedRaw = null;
_cachedParsed = null;
}
static String? formatFor(String actionId, {bool requireEnabled = true}) {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return null;
Map<String, dynamic>? parsed;
if (raw == _cachedRaw) {
parsed = _cachedParsed;
} else {
try {
parsed = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
parsed = null;
}
_cachedRaw = raw;
_cachedParsed = parsed;
}
if (parsed == null) return null;
if (requireEnabled && parsed['enabled'] != true) return null;
// When pass-through is on, the matcher returns early on every keystroke.
// Showing the bound combo next to a menu item would lie to the user — they
// would press it expecting the local action and instead the keys would go
// to the remote. Treat as unbound for display purposes.
if (requireEnabled && parsed['pass_through'] == true) return null;
final list = shortcutBindingMapsFrom(parsed['bindings']);
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<String>().toList()
: const <String>[];
// Plain-text labels (Cmd / Ctrl / Alt / Shift) instead of Unicode glyphs
// (⌘ ⌃ ⌥ ⇧). Flutter Web's CanvasKit bundled fonts don't always carry the
// macOS modifier symbols, which renders as garbled boxes on Mac browsers;
// text is portable and readable on every platform.
//
// Order matches the canonical macOS order (Cmd, Control, Option, Shift)
// so the rendered hint reads naturally. `ctrl` only ever appears in
// saved bindings on macOS — Win/Linux collapses Ctrl into `primary`.
final parts = <String>[];
for (final m in ['primary', 'ctrl', 'alt', 'shift']) {
if (!mods.contains(m)) continue;
switch (m) {
case 'primary': parts.add(isMac ? 'Cmd' : 'Ctrl'); break;
case 'ctrl': parts.add(isMac ? 'Control' : 'Ctrl'); break;
case 'alt': parts.add(isMac ? 'Option' : 'Alt'); break;
case 'shift': parts.add('Shift'); break;
}
}
parts.add(_keyDisplay(keyValue));
return parts.join('+');
}
static String _keyDisplay(String key) {
switch (key) {
case 'delete': return 'Del';
case 'backspace': return 'Backspace';
case 'enter': return 'Enter';
case 'tab': return 'Tab';
case 'space': return 'Space';
case 'arrow_left': return 'Left';
case 'arrow_right':return 'Right';
case 'arrow_up': return 'Up';
case 'arrow_down': return 'Down';
case 'home': return 'Home';
case 'end': return 'End';
case 'page_up': return 'PgUp';
case 'page_down': return 'PgDn';
case 'insert': return 'Ins';
}
if (key.startsWith('digit')) return key.substring(5);
// F-keys ("f1".."f12") and single letters fall through to uppercase.
return key.toUpperCase();
}
}

View File

@@ -0,0 +1,481 @@
// 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 'display.dart';
import 'recording_dialog.dart';
import 'shortcut_actions.dart';
import 'shortcut_utils.dart';
/// 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;
/// Whether to render the master Enable + Pass-through toggles inside the
/// body. Desktop shells set this to false because the General settings tab
/// already exposes both checkboxes (and is the only entry point to this
/// page on desktop). Mobile defaults to true: its entry point is a plain
/// nav tile in Settings, so this page is the only place the user can
/// flip the master switches.
final bool showMasterToggles;
const KeyboardShortcutsPageBody({
Key? key,
this.compact = true,
this.editButtonHint,
this.headerBanner,
this.showMasterToggles = true,
}) : super(key: key);
@override
State<KeyboardShortcutsPageBody> createState() =>
KeyboardShortcutsPageBodyState();
}
/// Public state so platform shells can call [resetToDefaultsWithConfirm] from
/// their AppBar action.
class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
// ----- Persistence helpers -----
Map<String, dynamic> _readJson() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return {'enabled': false, 'bindings': <dynamic>[]};
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
parsed['bindings'] ??= <dynamic>[];
parsed['enabled'] ??= false;
return parsed;
} catch (_) {
return {'enabled': false, 'bindings': <dynamic>[]};
}
}
Future<void> _writeJson(Map<String, dynamic> 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<void> _setBinding(
String actionId, {
Map<String, dynamic>? binding,
String? clearActionId,
}) async {
final json = _readJson();
final list = shortcutBindingMapsFrom(json['bindings']);
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<void> _setEnabled(bool v) async {
await ShortcutModel.setEnabled(v);
if (mounted) setState(() {});
}
Future<void> _setPassThrough(bool v) async {
await ShortcutModel.setPassThrough(v);
if (mounted) setState(() {});
}
Future<void> _resetToDefaults() async {
final json = _readJson();
// Single source of truth lives in `ShortcutModel.currentPlatformCapabilities`
// — the same helper feeds the first-enable seed pass, this Reset action,
// and the action-list filter below, so the three can never disagree on
// which actions belong on this platform.
json['bindings'] = filterDefaultBindingsForPlatform(
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
ShortcutModel.currentPlatformCapabilities(),
);
await _writeJson(json);
}
String _labelFor(String actionId) {
// Intentionally walks the unfiltered list (via the recursive helper, so
// both direct entries and subgroup entries are covered) — a stale
// cross-platform binding (e.g. Toggle Toolbar carried over from
// desktop) should still resolve to its human-readable label in conflict
// warnings.
for (final entry in allActionEntries(kKeyboardShortcutActionGroups)) {
if (entry.id == actionId) return translate(entry.labelKey);
}
return actionId;
}
/// Action groups visible on the current platform. Reads the same
/// capability set as the seed-defaults / reset-to-defaults paths from
/// `ShortcutModel.currentPlatformCapabilities`, so the UI lists exactly
/// the actions whose handlers the matcher can dispatch here.
List<KeyboardShortcutActionGroup> _groupsForCurrentPlatform() {
return filterKeyboardShortcutActionGroupsForPlatform(
ShortcutModel.currentPlatformCapabilities(),
);
}
// ----- UI handlers -----
Future<void> _onEdit(KeyboardShortcutActionEntry entry) async {
final json = _readJson();
final bindings = shortcutBindingMapsFrom(json['bindings']);
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<void> _onClear(KeyboardShortcutActionEntry entry) async {
await _setBinding(entry.id, binding: null);
}
/// Public — invoked from the platform AppBar action.
Future<void> resetToDefaultsWithConfirm() async {
final confirmed = await showDialog<bool>(
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),
],
if (widget.showMasterToggles) ...[
_toggleRow(
enabled,
'Enable keyboard shortcuts in remote session',
(v) => _setEnabled(v),
),
if (enabled)
_toggleRow(
ShortcutModel.isPassThrough(),
'Pass-through to remote',
(v) => _setPassThrough(v),
),
],
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),
// Bindings list and configuration entry only show when shortcuts are
// enabled — there is nothing to configure while the matcher is off.
if (enabled)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final group in _groupsForCurrentPlatform())
_buildGroup(context, group),
],
),
],
);
}
Widget _toggleRow(
bool value, String labelKey, Future<void> Function(bool) onChanged,
{String? tooltipKey}) {
return Row(
children: [
Checkbox(
value: value,
onChanged: (v) async {
if (v == null) return;
await onChanged(v);
},
),
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(!value),
child: Text(translate(labelKey)),
),
),
if (tooltipKey != null) InfoTooltipIcon(tipKey: tooltipKey),
],
);
}
// One indent unit per nesting level. Both "top item under top heading"
// and "subgroup heading under top group" are *one* level deeper than the
// top heading, so they share this indent — meaning a top-level direct
// item and a sibling subgroup heading line up at exactly the same x.
// Subgroup items are *two* levels deeper.
static const double _kIndentStep = 16.0;
/// Top-level group: heading at zero indent, then walk `children` in
/// declaration order. Direct entries get [_kIndentStep] of indent so
/// they read as "items under this heading"; subgroup headings sit at
/// the same indent (a subgroup is a sibling of the direct items, just
/// with its own nested entries below).
Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
_buildHeading(context, group.titleKey, isSub: false),
const SizedBox(height: 4),
for (final child in group.children)
switch (child) {
KeyboardShortcutActionEntry() => Padding(
padding: const EdgeInsets.only(left: _kIndentStep),
child: _buildEntryRow(context, child),
),
KeyboardShortcutActionSubgroup() =>
_buildSubgroup(context, child),
},
],
);
}
Widget _buildSubgroup(
BuildContext context, KeyboardShortcutActionSubgroup subgroup) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
_buildHeading(context, subgroup.titleKey, isSub: true),
const SizedBox(height: 4),
for (final entry in subgroup.entries)
Padding(
// Two indent steps: one for "subgroup heading is nested under
// top heading" (matches the heading's own indent) and one for
// "this entry is under the subgroup heading".
padding: const EdgeInsets.only(left: _kIndentStep * 2),
child: _buildEntryRow(context, entry),
),
],
);
}
Widget _buildHeading(BuildContext context, String titleKey,
{required bool isSub}) {
// Subgroup heading nests one step under the top heading — same indent
// as a top-level direct item, so the two line up at the same x.
final indent = isSub ? _kIndentStep : 0.0;
return Padding(
padding: EdgeInsets.only(left: 8 + indent, right: 8),
child: Row(
children: [
Text(
translate(titleKey),
style: TextStyle(
fontWeight: isSub ? FontWeight.w500 : FontWeight.w600,
color: isSub
? Theme.of(context).hintColor
: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 8),
Expanded(child: Divider(thickness: isSub ? 0.5 : 1)),
],
),
);
}
Widget _buildEntryRow(
BuildContext context, KeyboardShortcutActionEntry entry) {
return widget.compact
? _buildCompactRow(context, entry)
: _buildTouchRow(context, entry);
}
/// Desktop dense row: label | shortcut | edit | clear, all in one Row.
Widget _buildCompactRow(
BuildContext context, KeyboardShortcutActionEntry entry) {
final shortcut = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
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 = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
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),
],
),
);
}
}
/// Small help-icon tooltip used for inline explanations next to a checkbox /
/// row. Triggers on hover (desktop) and tap (mobile). Public so the desktop
/// General settings tab can reuse it.
class InfoTooltipIcon extends StatelessWidget {
final String tipKey;
const InfoTooltipIcon({Key? key, required this.tipKey}) : super(key: key);
@override
Widget build(BuildContext context) {
return Tooltip(
message: translate(tipKey),
triggerMode: TooltipTriggerMode.tap,
preferBelow: false,
waitDuration: const Duration(milliseconds: 250),
showDuration: const Duration(seconds: 6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
Icons.help_outline,
size: 16,
color: Theme.of(context).hintColor,
),
),
);
}
}

View File

@@ -0,0 +1,399 @@
// 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 that at least one
// modifier is present, 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';
import 'shortcut_utils.dart';
/// Result of the recording dialog.
class RecordingResult {
/// The new binding map to write: {action, mods, key}.
final Map<String, dynamic> 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<RecordingResult?> showRecordingDialog({
required BuildContext context,
required String actionId,
required String actionLabel,
required List<Map<String, dynamic>> existingBindings,
required String Function(String) actionLabelLookup,
}) {
return showDialog<RecordingResult>(
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<Map<String, dynamic>> 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<String> _mods = {};
String? _key;
// Human-readable label for the most recent press that we couldn't bind to
// (e.g. F13, media keys). null when the last press was either supported or
// a modifier-only press. Cleared whenever a supported key arrives, so a
// user who hits an unsupported key after a valid capture sees the warning
// until they press something else. Distinct from `_key == null` so the
// status line can tell the user *why* their press was ignored instead of
// silently doing nothing.
String? _unsupportedKey;
// Modifier LogicalKeyboardKeys we should *not* treat as "unsupported" when
// they fail to map to a key name. A modifier-only press is normal during
// combo capture (the user is building up their combo) — only non-modifier
// unmapped keys deserve the warning.
static final _modifierKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
LogicalKeyboardKey.control,
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.controlRight,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.altLeft,
LogicalKeyboardKey.altRight,
LogicalKeyboardKey.meta,
LogicalKeyboardKey.metaLeft,
LogicalKeyboardKey.metaRight,
LogicalKeyboardKey.capsLock,
LogicalKeyboardKey.numLock,
LogicalKeyboardKey.scrollLock,
LogicalKeyboardKey.fn,
LogicalKeyboardKey.fnLock,
};
@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 at least one modifier. Lower bound
/// for any sensible binding — pure single-key bindings would swallow normal
/// typing the moment shortcuts are enabled. Beyond one mod the user is on
/// their own; the in-session pass-through toggle is the escape hatch when
/// a chosen combo collides with something needed on the remote.
bool get _hasRequiredPrefix => _mods.isNotEmpty;
/// 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 = shortcutModSetFrom(b['mods']);
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 = logicalKeyName(logical);
// Mirror of `normalize_modifiers` in src/keyboard/shortcuts.rs:
// * macOS: Cmd → primary, Ctrl → ctrl (distinct).
// * Win/Linux: Ctrl → primary, no separate Ctrl modifier.
// The two halves must agree on labels, otherwise saved bindings will not
// match the events the matcher sees at runtime.
final mods = <String>{};
if (HardwareKeyboard.instance.isAltPressed) mods.add('alt');
if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift');
if (_isMac) {
if (HardwareKeyboard.instance.isMetaPressed) mods.add('primary');
if (HardwareKeyboard.instance.isControlPressed) mods.add('ctrl');
} else {
if (HardwareKeyboard.instance.isControlPressed) 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;
_unsupportedKey = null;
} else if (!_modifierKeys.contains(logical)) {
// Non-modifier key we don't recognize (e.g. F13, media keys, IME
// compose keys). Surface a warning instead of silently dropping the
// press — the dialog otherwise looks unresponsive.
final label = logical.keyLabel.isNotEmpty
? logical.keyLabel
: (logical.debugName ?? 'this key');
_unsupportedKey = label;
}
});
return KeyEventResult.handled;
}
void _onSave() {
if (_key == null || !_hasRequiredPrefix) return;
final ordered = canonicalShortcutModsForSave(_mods);
final binding = <String, dynamic>{
'action': widget.actionId,
'mods': ordered,
'key': _key!,
};
Navigator.of(context).pop(RecordingResult(binding, _conflictActionId));
}
String _formatPrefix() {
// Used in the "must include..." validation row; lists the modifier set
// a binding can pick from. Localised modifier glyphs aren't used here so
// the names stay greppable for users searching for "Option" / "Cmd".
if (_isMac) return 'Cmd / Control / Option / Shift';
return 'Ctrl / Alt / Shift';
}
String _formatCombo() {
// Plain-text labels (see same rationale in display.dart::_keyDisplay).
final parts = <String>[];
for (final m in ['primary', 'ctrl', 'alt', 'shift']) {
if (!_mods.contains(m)) continue;
switch (m) {
case 'primary':
parts.add(_isMac ? 'Cmd' : 'Ctrl');
break;
case 'ctrl':
parts.add(_isMac ? 'Control' : 'Ctrl');
break;
case 'alt':
parts.add(_isMac ? 'Option' : 'Alt');
break;
case 'shift':
parts.add('Shift');
break;
}
}
if (_key != null) {
parts.add(_keyDisplay(_key!));
}
if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip');
return parts.join('+');
}
String _keyDisplay(String key) {
switch (key) {
case 'delete': return 'Del';
case 'backspace': return 'Backspace';
case 'enter': return 'Enter';
case 'tab': return 'Tab';
case 'space': return 'Space';
case 'arrow_left': return 'Left';
case 'arrow_right':return 'Right';
case 'arrow_up': return 'Up';
case 'arrow_down': return 'Down';
case 'home': return 'Home';
case 'end': return 'End';
case 'page_up': return 'PgUp';
case 'page_down': return 'PgDn';
case 'insert': return 'Ins';
}
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;
// The Save button still fires for the previously-captured combo even if
// the user just hit an unsupported key — the captured state is what gets
// saved, the warning is just feedback that the latest press was rejected.
final canSave = hasKey && _hasRequiredPrefix;
Widget statusLine;
if (_unsupportedKey != null) {
// Most recent press was unsupported. Take precedence over the
// captured-combo states so the user gets explicit feedback that their
// last keystroke was ignored, regardless of whether a previous combo
// is still captured.
statusLine = Row(
children: [
const Icon(Icons.close, size: 16, color: Colors.red),
const SizedBox(width: 6),
Flexible(
child: Text(
translate('shortcut-key-not-supported')
.replaceAll('{}', _unsupportedKey!),
style: const TextStyle(color: Colors.red),
),
),
],
);
} else 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-modifiers')
.replaceAll('{}', _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),
],
);
}
}

View File

@@ -0,0 +1,292 @@
import 'shortcut_constants.dart';
import 'shortcut_utils.dart';
/// Marker for the union of [KeyboardShortcutActionEntry] /
/// [KeyboardShortcutActionSubgroup] — anything a top-level
/// [KeyboardShortcutActionGroup] can directly contain. Sealed so renderers
/// and filters can `switch` on it without a default branch.
sealed class KeyboardShortcutActionGroupChild {
const KeyboardShortcutActionGroupChild();
}
/// One configurable action — id + i18n key for its label.
class KeyboardShortcutActionEntry extends KeyboardShortcutActionGroupChild {
final String id;
final String labelKey;
const KeyboardShortcutActionEntry(this.id, this.labelKey);
}
/// A nested subgroup (e.g. "View Mode" under "Display"). Renders with extra
/// indent so its items are visually distinguished from the parent group's
/// direct items.
class KeyboardShortcutActionSubgroup extends KeyboardShortcutActionGroupChild {
final String titleKey;
final List<KeyboardShortcutActionEntry> entries;
const KeyboardShortcutActionSubgroup(this.titleKey, this.entries);
}
/// A top-level group ("Display", "Keyboard", "Chat", …). `children` is an
/// *ordered* mix of direct entries and subgroups, so layouts like
/// "subgroups first → direct items → trailing subgroup" — exactly the
/// shape `_DisplayMenu` uses (Privacy mode lives after the cursor / display
/// toggles direct items) — are first-class instead of needing a wrapper
/// "Display Settings" subgroup just to insert the items.
class KeyboardShortcutActionGroup {
final String titleKey;
final List<KeyboardShortcutActionGroupChild> children;
const KeyboardShortcutActionGroup(this.titleKey, this.children);
}
/// Canonical action group definitions used by both the desktop and mobile
/// configuration pages. The order of groups, subgroups, and entries here
/// is the order the user sees in the UI, and mirrors the corresponding
/// toolbar submenu (`_DisplayMenu` / `_KeyboardMenu` in
/// `desktop/widgets/remote_toolbar.dart`) child order — modulo entries
/// without shortcut counterparts (e.g. `_screenAdjustor.adjustWindow`,
/// `scrollStyle`, `_ResolutionsMenu`, `localKeyboardType`).
final List<KeyboardShortcutActionGroup> kKeyboardShortcutActionGroups = [
KeyboardShortcutActionGroup('Monitor', [
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayNext, 'Switch to next display'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayPrev, 'Switch to previous display'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayAll, 'All monitors'),
]),
KeyboardShortcutActionGroup('Control Actions', [
KeyboardShortcutActionEntry(
kShortcutActionSendClipboardKeystrokes, 'Send clipboard keystrokes'),
KeyboardShortcutActionEntry(kShortcutActionResetCanvas, 'Reset canvas'),
KeyboardShortcutActionEntry(
kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'),
KeyboardShortcutActionEntry(
kShortcutActionRestartRemote, 'Restart remote device'),
KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'),
KeyboardShortcutActionEntry(
kShortcutActionToggleBlockInput, 'Block user input'),
KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'),
KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'),
KeyboardShortcutActionEntry(
kShortcutActionToggleRecording, 'Toggle session recording'),
KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take screenshot'),
]),
// Display: subgroups (View Mode → Image Quality → Codec → Virtual display)
// first, then the direct items (cursor toggles + display toggles), then
// Privacy mode subgroup last — matching `_DisplayMenu.menuChildrenGetter`
// exactly. Rebalancing this order should also rebalance the toolbar.
KeyboardShortcutActionGroup('Display', [
KeyboardShortcutActionSubgroup('View Mode', [
KeyboardShortcutActionEntry(
kShortcutActionViewModeOriginal, 'Scale original'),
KeyboardShortcutActionEntry(
kShortcutActionViewModeAdaptive, 'Scale adaptive'),
KeyboardShortcutActionEntry(
kShortcutActionViewModeCustom, 'Scale custom'),
]),
KeyboardShortcutActionSubgroup('Image Quality', [
KeyboardShortcutActionEntry(
kShortcutActionImageQualityBest, 'Good image quality'),
KeyboardShortcutActionEntry(
kShortcutActionImageQualityBalanced, 'Balanced'),
KeyboardShortcutActionEntry(
kShortcutActionImageQualityLow, 'Optimize reaction time'),
]),
KeyboardShortcutActionSubgroup('Codec', [
KeyboardShortcutActionEntry(kShortcutActionCodecAuto, 'Auto'),
KeyboardShortcutActionEntry(kShortcutActionCodecVp8, 'VP8'),
KeyboardShortcutActionEntry(kShortcutActionCodecVp9, 'VP9'),
KeyboardShortcutActionEntry(kShortcutActionCodecAv1, 'AV1'),
KeyboardShortcutActionEntry(kShortcutActionCodecH264, 'H264'),
KeyboardShortcutActionEntry(kShortcutActionCodecH265, 'H265'),
]),
KeyboardShortcutActionSubgroup('Virtual display', [
KeyboardShortcutActionEntry(
kShortcutActionPlugOutAllVirtualDisplays, 'Plug out all'),
]),
// Direct items: cursorToggles + display toggles, in toolbar order.
KeyboardShortcutActionEntry(
kShortcutActionToggleShowRemoteCursor, 'Show remote cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleFollowRemoteCursor, 'Follow remote cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleFollowRemoteWindow, 'Follow remote window focus'),
KeyboardShortcutActionEntry(
kShortcutActionToggleZoomCursor, 'Zoom cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleQualityMonitor, 'Show quality monitor'),
KeyboardShortcutActionEntry(kShortcutActionToggleMute, 'Mute'),
KeyboardShortcutActionEntry(
kShortcutActionToggleEnableFileCopyPaste, 'Enable file copy and paste'),
KeyboardShortcutActionEntry(
kShortcutActionToggleDisableClipboard, 'Disable clipboard'),
KeyboardShortcutActionEntry(
kShortcutActionToggleLockAfterSessionEnd, 'Lock after session end'),
KeyboardShortcutActionEntry(
kShortcutActionToggleTrueColor, 'True color (4:4:4)'),
// Privacy mode at the bottom — mirrors `_DisplayMenu` where it's the
// last submenu added (line ~1023 of remote_toolbar.dart, after toggles).
KeyboardShortcutActionSubgroup('Privacy mode', [
// Reuse toolbar's existing impl-name i18n keys. The handler at
// runtime matches `privacy_mode_impl_mag_tip` /
// `privacy_mode_impl_virtual_display_tip` against the peer's
// advertised impls — same logic the toolbar's `toolbarPrivacyMode`
// submenu uses.
KeyboardShortcutActionEntry(
kShortcutActionPrivacyMode1, 'privacy_mode_impl_mag_tip'),
KeyboardShortcutActionEntry(
kShortcutActionPrivacyMode2, 'privacy_mode_impl_virtual_display_tip'),
]),
]),
// Keyboard: Keyboard mode subgroup first, then direct items
// (inputSource → viewMode → showMyCursor → toolbarKeyboardToggles),
// matching `_KeyboardMenu.menuChildrenGetter`.
KeyboardShortcutActionGroup('Keyboard', [
KeyboardShortcutActionSubgroup('Keyboard mode', [
KeyboardShortcutActionEntry(
kShortcutActionKeyboardModeLegacy, 'Legacy mode'),
KeyboardShortcutActionEntry(kShortcutActionKeyboardModeMap, 'Map mode'),
KeyboardShortcutActionEntry(
kShortcutActionKeyboardModeTranslate, 'Translate mode'),
]),
KeyboardShortcutActionEntry(
kShortcutActionToggleInputSource, 'Toggle input source'),
KeyboardShortcutActionEntry(kShortcutActionToggleViewOnly, 'View Mode'),
KeyboardShortcutActionEntry(
kShortcutActionToggleShowMyCursor, 'Show my cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleSwapCtrlCmd, 'Swap control-command key'),
KeyboardShortcutActionEntry(
kShortcutActionToggleRelativeMouseMode, 'Relative mouse mode'),
KeyboardShortcutActionEntry(
kShortcutActionToggleReverseMouseWheel, 'Reverse mouse wheel'),
KeyboardShortcutActionEntry(
kShortcutActionToggleSwapLeftRightMouse, 'swap-left-right-mouse'),
]),
KeyboardShortcutActionGroup('Chat', [
KeyboardShortcutActionEntry(kShortcutActionToggleChat, 'Text chat'),
KeyboardShortcutActionEntry(kShortcutActionToggleVoiceCall, 'Voice call'),
]),
// "Other" collects single-icon toolbar buttons that have no dropdown
// (Pin, Close), plus actions with no toolbar entry at all (Fullscreen —
// driven by callback, not menu; Toggle Toolbar / tab navigation — tab
// right-click menu, not toolbar). Combined into one group rather than
// several 1-item groups for cleaner visual hierarchy.
KeyboardShortcutActionGroup('Other', [
KeyboardShortcutActionEntry(kShortcutActionPinToolbar, 'Pin Toolbar'),
KeyboardShortcutActionEntry(
kShortcutActionToggleFullscreen, 'Toggle fullscreen'),
KeyboardShortcutActionEntry(kShortcutActionToggleToolbar, 'Toggle toolbar'),
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close tab'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchTabNext, 'Switch to next tab'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchTabPrev, 'Switch to previous tab'),
]),
];
/// Walk the (filtered or unfiltered) group tree and yield every
/// [KeyboardShortcutActionEntry], regardless of whether it sits as a direct
/// child of a top-level group or inside a subgroup. Useful for label
/// lookups, ghost-action tests, and any consumer that just wants the flat
/// list of action ids.
Iterable<KeyboardShortcutActionEntry> allActionEntries(
Iterable<KeyboardShortcutActionGroup> groups,
) sync* {
for (final group in groups) {
for (final child in group.children) {
switch (child) {
case KeyboardShortcutActionEntry():
yield child;
case KeyboardShortcutActionSubgroup():
yield* child.entries;
}
}
}
}
/// Return [kKeyboardShortcutActionGroups] with actions that aren't supported
/// on the current platform stripped out. Subgroups whose every entry was
/// filtered are dropped; top-level groups whose every child (direct entry
/// or subgroup) was dropped are themselves dropped.
///
/// Mirrors the capability flags used by [filterDefaultBindingsForPlatform]
/// so the configuration UI shows only what the matcher can actually
/// dispatch on this platform.
///
/// Note: callers should still walk the unfiltered
/// [kKeyboardShortcutActionGroups] for label lookups (e.g. conflict
/// warnings about a stale cross-platform binding), so an action bound on
/// desktop and carried over to mobile still has a human-readable name in
/// dialogs.
List<KeyboardShortcutActionGroup> filterKeyboardShortcutActionGroupsForPlatform(
ShortcutPlatformCapabilities cap,
) {
bool allowed(String id) {
if (!cap.includeFullscreenShortcut &&
id == kShortcutActionToggleFullscreen) {
return false;
}
if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) {
return false;
}
if (!cap.includeScreenshotShortcut &&
id == kShortcutActionToggleRelativeMouseMode) {
return false;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(id)) return false;
if (!cap.includeToolbarShortcut && id == kShortcutActionToggleToolbar) {
return false;
}
if (!cap.includeCloseTabShortcut && id == kShortcutActionCloseTab) {
return false;
}
if (!cap.includeSwitchSidesShortcut && id == kShortcutActionSwitchSides) {
return false;
}
if (!cap.includeRecordingShortcut && id == kShortcutActionToggleRecording) {
return false;
}
if (!cap.includeResetCanvasShortcut && id == kShortcutActionResetCanvas) {
return false;
}
if (!cap.includePinToolbarShortcut && id == kShortcutActionPinToolbar) {
return false;
}
if (!cap.includeViewModeShortcut &&
(id == kShortcutActionViewModeOriginal ||
id == kShortcutActionViewModeAdaptive ||
id == kShortcutActionViewModeCustom)) {
return false;
}
if (!cap.includeInputSourceShortcut &&
id == kShortcutActionToggleInputSource) {
return false;
}
if (!cap.includeVoiceCallShortcut && id == kShortcutActionToggleVoiceCall) {
return false;
}
return true;
}
final out = <KeyboardShortcutActionGroup>[];
for (final group in kKeyboardShortcutActionGroups) {
final filteredChildren = <KeyboardShortcutActionGroupChild>[];
for (final child in group.children) {
switch (child) {
case KeyboardShortcutActionEntry():
if (allowed(child.id)) filteredChildren.add(child);
case KeyboardShortcutActionSubgroup():
final entries =
child.entries.where((e) => allowed(e.id)).toList();
if (entries.isNotEmpty) {
filteredChildren.add(
KeyboardShortcutActionSubgroup(child.titleKey, entries));
}
}
}
if (filteredChildren.isNotEmpty) {
out.add(KeyboardShortcutActionGroup(group.titleKey, filteredChildren));
}
}
return out;
}

View File

@@ -0,0 +1,104 @@
/// 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 kShortcutActionSwitchDisplayAll = 'switch_display_all';
const kShortcutActionScreenshot = 'screenshot';
const kShortcutActionInsertLock = 'insert_lock';
const kShortcutActionRefresh = 'refresh';
const kShortcutActionToggleBlockInput = 'toggle_block_input';
const kShortcutActionToggleRecording = 'toggle_recording';
const kShortcutActionSwitchSides = 'switch_sides';
const kShortcutActionCloseTab = 'close_tab';
const kShortcutActionToggleToolbar = 'toggle_toolbar';
const kShortcutActionRestartRemote = 'restart_remote';
const kShortcutActionResetCanvas = 'reset_canvas';
const kShortcutActionSwitchTabNext = 'switch_tab_next';
const kShortcutActionSwitchTabPrev = 'switch_tab_prev';
const kShortcutActionToggleMute = 'toggle_mute';
const kShortcutActionPinToolbar = 'pin_toolbar';
const kShortcutActionViewModeOriginal = 'view_mode_original';
const kShortcutActionViewModeAdaptive = 'view_mode_adaptive';
const kShortcutActionToggleChat = 'toggle_chat';
const kShortcutActionToggleQualityMonitor = 'toggle_quality_monitor';
const kShortcutActionToggleShowRemoteCursor = 'toggle_show_remote_cursor';
const kShortcutActionToggleShowMyCursor = 'toggle_show_my_cursor';
const kShortcutActionToggleDisableClipboard = 'toggle_disable_clipboard';
const kShortcutActionPrivacyMode1 = 'privacy_mode_1';
const kShortcutActionPrivacyMode2 = 'privacy_mode_2';
// Keyboard mode (Map / Translate / Legacy).
const kShortcutActionKeyboardModeMap = 'keyboard_mode_map';
const kShortcutActionKeyboardModeTranslate = 'keyboard_mode_translate';
const kShortcutActionKeyboardModeLegacy = 'keyboard_mode_legacy';
// Codec preference (Auto + the four optional codecs the toolbar surfaces).
const kShortcutActionCodecAuto = 'codec_auto';
const kShortcutActionCodecVp8 = 'codec_vp8';
const kShortcutActionCodecVp9 = 'codec_vp9';
const kShortcutActionCodecAv1 = 'codec_av1';
const kShortcutActionCodecH264 = 'codec_h264';
const kShortcutActionCodecH265 = 'codec_h265';
// Plug out every virtual display in one shot — toolbar exposes this in
// both IDD modes (RustDesk and Amyuni). Per-index virtual-display toggles
// (RustDesk IDD's 4 checkboxes) and the +/- count buttons (Amyuni-only)
// are NOT exposed as shortcuts: per-index is too granular, and +/- has
// no toolbar counterpart on RustDesk IDD peers.
const kShortcutActionPlugOutAllVirtualDisplays =
'plug_out_all_virtual_displays';
const kShortcutActionToggleRelativeMouseMode = 'toggle_relative_mouse_mode';
const kShortcutActionToggleFollowRemoteCursor = 'toggle_follow_remote_cursor';
const kShortcutActionToggleFollowRemoteWindow = 'toggle_follow_remote_window';
const kShortcutActionToggleZoomCursor = 'toggle_zoom_cursor';
const kShortcutActionToggleReverseMouseWheel = 'toggle_reverse_mouse_wheel';
const kShortcutActionToggleSwapLeftRightMouse = 'toggle_swap_left_right_mouse';
const kShortcutActionToggleLockAfterSessionEnd = 'toggle_lock_after_session_end';
const kShortcutActionToggleTrueColor = 'toggle_true_color';
const kShortcutActionToggleSwapCtrlCmd = 'toggle_swap_ctrl_cmd';
const kShortcutActionToggleEnableFileCopyPaste = 'toggle_enable_file_copy_paste';
const kShortcutActionViewModeCustom = 'view_mode_custom';
const kShortcutActionImageQualityBest = 'image_quality_best';
const kShortcutActionImageQualityBalanced = 'image_quality_balanced';
const kShortcutActionImageQualityLow = 'image_quality_low';
const kShortcutActionSendClipboardKeystrokes = 'send_clipboard_keystrokes';
const kShortcutActionToggleInputSource = 'toggle_input_source';
const kShortcutActionToggleVoiceCall = 'toggle_voice_call';
const kShortcutActionToggleViewOnly = 'toggle_view_only';
const kShortcutLocalConfigKey = 'keyboard-shortcuts';
const kShortcutEventName = 'shortcut_triggered';
/// Canonical default keyboard-shortcut bindings, mirroring Rust's
/// `default_bindings()` in `src/keyboard/shortcuts.rs`. Used by:
/// * the Web bridge (`flutter/lib/web/bridge.dart::mainGetDefaultKeyboardShortcuts`)
/// — Web has no Rust at runtime, so the seed list is read from this Dart
/// constant instead of going through FFI.
/// * the configuration page when seeding defaults on first enable, after
/// [filterDefaultBindingsForPlatform] has trimmed platform-specific
/// entries.
///
/// Parity with Rust is unit-tested on both sides against
/// `flutter/test/fixtures/default_keyboard_shortcuts.json` — see the
/// `kDefaultShortcutBindings matches fixture` test in
/// `flutter/test/keyboard_shortcuts_test.dart` and
/// `default_bindings_match_fixture_json` in `src/keyboard/shortcuts.rs`.
/// Any change here MUST also update the fixture and the Rust source, or CI
/// will fail in the side that drifted.
final List<Map<String, Object>> kDefaultShortcutBindings = [
for (final entry in <List<Object>>[
[kShortcutActionSendCtrlAltDel, 'delete'],
[kShortcutActionToggleFullscreen, 'enter'],
[kShortcutActionSwitchDisplayNext, 'arrow_right'],
[kShortcutActionSwitchDisplayPrev, 'arrow_left'],
[kShortcutActionScreenshot, 'p'],
[kShortcutActionToggleShowRemoteCursor, 'm'],
[kShortcutActionToggleMute, 's'],
[kShortcutActionToggleBlockInput, 'i'],
[kShortcutActionToggleChat, 'c'],
])
{
'action': entry[0],
'mods': const ['primary', 'alt', 'shift'],
'key': entry[1],
},
];

View File

@@ -0,0 +1,226 @@
import 'package:flutter/services.dart';
import 'shortcut_constants.dart';
List<String> canonicalShortcutModsForSave(Set<String> mods) {
return <String>[
if (mods.contains('primary')) 'primary',
if (mods.contains('ctrl')) 'ctrl',
if (mods.contains('alt')) 'alt',
if (mods.contains('shift')) 'shift',
];
}
List<Map<String, dynamic>> shortcutBindingMapsFrom(dynamic rawBindings) {
if (rawBindings is! Iterable) return <Map<String, dynamic>>[];
final bindings = <Map<String, dynamic>>[];
for (final raw in rawBindings) {
if (raw is! Map) continue;
final binding = <String, dynamic>{};
for (final entry in raw.entries) {
final key = entry.key;
if (key is String) {
binding[key] = entry.value;
}
}
if (binding.isNotEmpty) {
bindings.add(binding);
}
}
return bindings;
}
Set<String> shortcutModSetFrom(dynamic rawMods) {
if (rawMods is! Iterable) return <String>{};
return rawMods.whereType<String>().toSet();
}
bool isSwitchTabShortcutAction(String? actionId) {
return actionId == kShortcutActionSwitchTabNext ||
actionId == kShortcutActionSwitchTabPrev;
}
/// Map a [LogicalKeyboardKey] to the canonical key name used in saved
/// bindings, or `null` for keys we don't accept as shortcuts.
///
/// 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. Cross-language parity is enforced by:
/// * `flutter/test/fixtures/supported_shortcut_keys.json` — the
/// authoritative list of names this function must produce.
/// * Dart `supported keys` test in `keyboard_shortcuts_test.dart` —
/// asserts the (LogicalKeyboardKey → name) mapping covers the fixture.
/// * Rust `supported_keys_match_fixture` test in `shortcuts.rs` — the
/// Rust-side mirror against the same fixture.
/// A drift in any of the three breaks one of the two tests.
String? logicalKeyName(LogicalKeyboardKey k) {
// Singletons that map 1:1.
if (k == LogicalKeyboardKey.delete) return 'delete';
if (k == LogicalKeyboardKey.backspace) return 'backspace';
// Numpad Enter shares the "enter" name with the main Return key — matches
// the Rust matcher (`Return | KpReturn` → "enter") and matches user
// expectation that the two physical Enters are interchangeable.
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'enter';
}
if (k == LogicalKeyboardKey.tab) return 'tab';
if (k == LogicalKeyboardKey.space) return 'space';
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';
if (k == LogicalKeyboardKey.home) return 'home';
if (k == LogicalKeyboardKey.end) return 'end';
if (k == LogicalKeyboardKey.pageUp) return 'page_up';
if (k == LogicalKeyboardKey.pageDown) return 'page_down';
if (k == LogicalKeyboardKey.insert) return 'insert';
// Letter / digit / F-key tables. `LogicalKeyboardKey` constants are
// `static final` (not `const`), so the maps can't be `const` — but they
// initialize once per process and the lookup is O(1).
final letters = <LogicalKeyboardKey, String>{
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',
};
final letter = letters[k];
if (letter != null) return letter;
final digits = <LogicalKeyboardKey, String>{
LogicalKeyboardKey.digit0: 'digit0',
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',
};
final digit = digits[k];
if (digit != null) return digit;
final fkeys = <LogicalKeyboardKey, String>{
LogicalKeyboardKey.f1: 'f1', LogicalKeyboardKey.f2: 'f2',
LogicalKeyboardKey.f3: 'f3', LogicalKeyboardKey.f4: 'f4',
LogicalKeyboardKey.f5: 'f5', LogicalKeyboardKey.f6: 'f6',
LogicalKeyboardKey.f7: 'f7', LogicalKeyboardKey.f8: 'f8',
LogicalKeyboardKey.f9: 'f9', LogicalKeyboardKey.f10: 'f10',
LogicalKeyboardKey.f11: 'f11', LogicalKeyboardKey.f12: 'f12',
};
return fkeys[k];
}
/// Bundle of "is this shortcut available on the current platform" flags.
///
/// Production code reaches a single source of truth via
/// [ShortcutModel.currentPlatformCapabilities] (which encodes the per-runtime
/// rules in one place); tests construct one directly with whichever flags
/// they want to exercise. Two filter functions consume this:
/// [filterDefaultBindingsForPlatform] (for trimming default-binding JSON
/// before it hits LocalConfig) and [filterKeyboardShortcutActionGroupsForPlatform]
/// (for trimming the configuration UI's action list). Both must agree on the
/// same capability set, otherwise a default binding could be seeded for an
/// action the user has no UI to manage.
class ShortcutPlatformCapabilities {
final bool includeFullscreenShortcut;
final bool includeScreenshotShortcut;
final bool includeTabShortcuts;
final bool includeToolbarShortcut;
final bool includeCloseTabShortcut;
final bool includeSwitchSidesShortcut;
final bool includeRecordingShortcut;
final bool includeResetCanvasShortcut;
final bool includePinToolbarShortcut;
final bool includeViewModeShortcut;
final bool includeInputSourceShortcut;
final bool includeVoiceCallShortcut;
const ShortcutPlatformCapabilities({
required this.includeFullscreenShortcut,
required this.includeScreenshotShortcut,
required this.includeTabShortcuts,
required this.includeToolbarShortcut,
required this.includeCloseTabShortcut,
required this.includeSwitchSidesShortcut,
required this.includeRecordingShortcut,
required this.includeResetCanvasShortcut,
required this.includePinToolbarShortcut,
required this.includeViewModeShortcut,
required this.includeInputSourceShortcut,
required this.includeVoiceCallShortcut,
});
}
List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
Iterable<dynamic> bindings,
ShortcutPlatformCapabilities cap,
) {
final filtered = <Map<String, dynamic>>[];
for (final binding in shortcutBindingMapsFrom(bindings)) {
final action = binding['action'] as String?;
if (!cap.includeFullscreenShortcut &&
action == kShortcutActionToggleFullscreen) {
continue;
}
if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) {
continue;
}
if (!cap.includeScreenshotShortcut &&
action == kShortcutActionToggleRelativeMouseMode) {
continue;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) {
continue;
}
if (!cap.includeToolbarShortcut &&
action == kShortcutActionToggleToolbar) {
continue;
}
if (!cap.includeCloseTabShortcut && action == kShortcutActionCloseTab) {
continue;
}
if (!cap.includeSwitchSidesShortcut &&
action == kShortcutActionSwitchSides) {
continue;
}
if (!cap.includeRecordingShortcut &&
action == kShortcutActionToggleRecording) {
continue;
}
if (!cap.includeResetCanvasShortcut &&
action == kShortcutActionResetCanvas) {
continue;
}
if (!cap.includePinToolbarShortcut && action == kShortcutActionPinToolbar) {
continue;
}
if (!cap.includeViewModeShortcut &&
(action == kShortcutActionViewModeOriginal ||
action == kShortcutActionViewModeAdaptive ||
action == kShortcutActionViewModeCustom)) {
continue;
}
if (!cap.includeInputSourceShortcut &&
action == kShortcutActionToggleInputSource) {
continue;
}
if (!cap.includeVoiceCallShortcut &&
action == kShortcutActionToggleVoiceCall) {
continue;
}
filtered.add(binding);
}
return filtered;
}

View File

@@ -11,21 +11,83 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/shortcut_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
bool isEditOsPassword = false;
/// Action IDs that `toolbarControls` is the sole registrar for. Wiped on
/// every call so stale closures don't outlive the menu entry that owned
/// them. Actions registered by `registerSessionShortcutActions` MUST NOT
/// appear here. `kShortcutActionToggleRecording` is platform-conditional
/// and handled separately in the unregister pass below.
const _kToolbarOwnedActionIds = <String>[
kShortcutActionSendCtrlAltDel,
kShortcutActionRestartRemote,
kShortcutActionInsertLock,
kShortcutActionToggleBlockInput,
kShortcutActionSwitchSides,
kShortcutActionRefresh,
kShortcutActionScreenshot,
kShortcutActionResetCanvas,
kShortcutActionSendClipboardKeystrokes,
];
const _kToolbarViewStyleActionIds = <String>[
kShortcutActionViewModeOriginal,
kShortcutActionViewModeAdaptive,
kShortcutActionViewModeCustom,
];
const _kToolbarImageQualityActionIds = <String>[
kShortcutActionImageQualityBest,
kShortcutActionImageQualityBalanced,
kShortcutActionImageQualityLow,
];
const _kToolbarCodecActionIds = <String>[
kShortcutActionCodecAuto,
kShortcutActionCodecVp8,
kShortcutActionCodecVp9,
kShortcutActionCodecAv1,
kShortcutActionCodecH264,
kShortcutActionCodecH265,
];
const _kToolbarCursorActionIds = <String>[
kShortcutActionToggleShowRemoteCursor,
kShortcutActionToggleFollowRemoteCursor,
kShortcutActionToggleFollowRemoteWindow,
kShortcutActionToggleZoomCursor,
];
const _kToolbarDisplayToggleActionIds = <String>[
kShortcutActionToggleQualityMonitor,
kShortcutActionToggleMute,
kShortcutActionToggleEnableFileCopyPaste,
kShortcutActionToggleDisableClipboard,
kShortcutActionToggleLockAfterSessionEnd,
kShortcutActionToggleTrueColor,
];
const _kToolbarKeyboardToggleActionIds = <String>[
kShortcutActionToggleSwapCtrlCmd,
kShortcutActionToggleSwapLeftRightMouse,
];
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) {
@@ -47,20 +109,73 @@ class TRadioMenu<T> {
final T value;
final T groupValue;
final ValueChanged<T?>? onChanged;
final String? actionId;
TRadioMenu(
{required this.child,
required this.value,
required this.groupValue,
required this.onChanged});
required this.onChanged,
this.actionId});
}
class TToggleMenu {
final Widget child;
final bool value;
final ValueChanged<bool?>? onChanged;
final String? actionId;
TToggleMenu(
{required this.child, required this.value, required this.onChanged});
{required this.child,
required this.value,
required this.onChanged,
this.actionId});
}
/// Register each tagged entry's `onChanged` with the session [ShortcutModel].
/// Passthrough — returns [menus] so a caller can wrap `return [...]` directly.
List<TToggleMenu> _registerToggleMenuShortcuts(
FFI ffi,
List<TToggleMenu> menus, {
List<String> ownedActionIds = const [],
}) {
for (final actionId in ownedActionIds) {
ffi.shortcutModel.unregister(actionId);
}
for (final menu in menus) {
final actionId = menu.actionId;
if (actionId == null) continue;
final onChanged = menu.onChanged;
if (onChanged == null) {
ffi.shortcutModel.unregister(actionId);
} else {
final value = menu.value;
ffi.shortcutModel.register(actionId, () => onChanged(!value));
}
}
return menus;
}
/// Radio variant of [_registerToggleMenuShortcuts].
List<TRadioMenu<T>> _registerRadioMenuShortcuts<T>(
FFI ffi,
List<TRadioMenu<T>> menus, {
List<String> ownedActionIds = const [],
}) {
for (final actionId in ownedActionIds) {
ffi.shortcutModel.unregister(actionId);
}
for (final menu in menus) {
final actionId = menu.actionId;
if (actionId == null) continue;
final onChanged = menu.onChanged;
if (onChanged == null) {
ffi.shortcutModel.unregister(actionId);
} else {
final value = menu.value;
ffi.shortcutModel.register(actionId, () => onChanged(value));
}
}
return menus;
}
handleOsPasswordEditIcon(
@@ -94,6 +209,17 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
// Wipe stale registrations from previous menu builds before re-registering
// below; runs unconditionally so mid-session enable works without reconnect.
for (final actionId in _kToolbarOwnedActionIds) {
ffi.shortcutModel.unregister(actionId);
}
// toggle_recording is mobile-only here; desktop's registration is owned by
// `registerSessionShortcutActions` and must not be touched.
if (!(isDesktop || isWeb)) {
ffi.shortcutModel.unregister(kShortcutActionToggleRecording);
}
List<TTextMenu> v = [];
// elevation
if (isDefaultConn &&
@@ -147,13 +273,15 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
}
}));
},
actionId: kShortcutActionSendClipboardKeystrokes));
}
// reset canvas
if (isDefaultConn && isMobile) {
v.add(TTextMenu(
child: Text(translate('Reset canvas')),
onPressed: () => ffi.cursorModel.reset()));
onPressed: () => ffi.cursorModel.reset(),
actionId: kShortcutActionResetCanvas));
}
// https://github.com/rustdesk/rustdesk/pull/9731
@@ -229,7 +357,8 @@ List<TTextMenu> 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 +371,8 @@ List<TTextMenu> 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 +380,8 @@ List<TTextMenu> 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 +399,8 @@ List<TTextMenu> 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 +412,15 @@ List<TTextMenu> 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 +442,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
)
],
),
onPressed: () => ffi.recordingModel.toggle()));
onPressed: () => ffi.recordingModel.toggle(),
actionId: kShortcutActionToggleRecording));
}
// to-do:
@@ -325,6 +460,14 @@ List<TTextMenu> 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 +485,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
});
}
},
actionId: kShortcutActionScreenshot,
));
}
}
@@ -352,6 +496,17 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
onPressed: () => onCopyFingerprint(FingerprintState.find(id).value),
));
}
// Register tagged TTextMenu callbacks. The else-unregister is defense in
// depth for actionIds tagged but missing from `_kToolbarOwnedActionIds`.
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;
}
@@ -366,23 +521,26 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
.then((_) => ffi.canvasModel.updateViewStyle());
}
return [
return _registerRadioMenuShortcuts(ffi, [
TRadioMenu<String>(
child: Text(translate('Scale original')),
value: kRemoteViewStyleOriginal,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionViewModeOriginal),
TRadioMenu<String>(
child: Text(translate('Scale adaptive')),
value: kRemoteViewStyleAdaptive,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionViewModeAdaptive),
TRadioMenu<String>(
child: Text(translate('Scale custom')),
value: kRemoteViewStyleCustom,
groupValue: groupValue,
onChanged: onChanged)
];
onChanged: onChanged,
actionId: kShortcutActionViewModeCustom)
], ownedActionIds: _kToolbarViewStyleActionIds);
}
Future<List<TRadioMenu<String>>> toolbarImageQuality(
@@ -394,22 +552,25 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value);
}
return [
return _registerRadioMenuShortcuts(ffi, [
TRadioMenu<String>(
child: Text(translate('Good image quality')),
value: kRemoteImageQualityBest,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionImageQualityBest),
TRadioMenu<String>(
child: Text(translate('Balanced')),
value: kRemoteImageQualityBalanced,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionImageQualityBalanced),
TRadioMenu<String>(
child: Text(translate('Optimize reaction time')),
value: kRemoteImageQualityLow,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionImageQualityLow),
TRadioMenu<String>(
child: Text(translate('Custom')),
value: kRemoteImageQualityCustom,
@@ -419,7 +580,7 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
customImageQualityDialog(ffi.sessionId, id, ffi);
},
),
];
], ownedActionIds: _kToolbarImageQualityActionIds);
}
Future<List<TRadioMenu<String>>> toolbarCodec(
@@ -446,7 +607,10 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
}
final visible =
codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]);
if (!visible) return [];
if (!visible) {
return _registerRadioMenuShortcuts<String>(ffi, [],
ownedActionIds: _kToolbarCodecActionIds);
}
onChanged(String? value) async {
if (value == null) return;
await bind.sessionPeerOption(
@@ -454,12 +618,14 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
bind.sessionChangePreferCodec(sessionId: sessionId);
}
TRadioMenu<String> radio(String label, String value, bool enabled) {
TRadioMenu<String> radio(
String label, String value, bool enabled, String actionId) {
return TRadioMenu<String>(
child: Text(label),
value: value,
groupValue: groupValue,
onChanged: enabled ? onChanged : null);
onChanged: enabled ? onChanged : null,
actionId: actionId);
}
var autoLabel = translate('Auto');
@@ -467,14 +633,14 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
ffi.qualityMonitorModel.data.codecFormat != null) {
autoLabel = '$autoLabel (${ffi.qualityMonitorModel.data.codecFormat})';
}
return [
radio(autoLabel, 'auto', true),
if (codecs[0]) radio('VP8', 'vp8', codecs[0]),
radio('VP9', 'vp9', true),
if (codecs[1]) radio('AV1', 'av1', codecs[1]),
if (codecs[2]) radio('H264', 'h264', codecs[2]),
if (codecs[3]) radio('H265', 'h265', codecs[3]),
];
return _registerRadioMenuShortcuts(ffi, [
radio(autoLabel, 'auto', true, kShortcutActionCodecAuto),
if (codecs[0]) radio('VP8', 'vp8', codecs[0], kShortcutActionCodecVp8),
radio('VP9', 'vp9', true, kShortcutActionCodecVp9),
if (codecs[1]) radio('AV1', 'av1', codecs[1], kShortcutActionCodecAv1),
if (codecs[2]) radio('H264', 'h264', codecs[2], kShortcutActionCodecH264),
if (codecs[3]) radio('H265', 'h265', codecs[3], kShortcutActionCodecH265),
], ownedActionIds: _kToolbarCodecActionIds);
}
Future<List<TToggleMenu>> toolbarCursor(
@@ -499,6 +665,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Show remote cursor')),
value: state.value,
actionId: kShortcutActionToggleShowRemoteCursor,
onChanged: enabled && !lockState.value
? (value) async {
if (value == null) return;
@@ -535,6 +702,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Follow remote cursor')),
value: value,
actionId: kShortcutActionToggleFollowRemoteCursor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -563,6 +731,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Follow remote window focus')),
value: value,
actionId: kShortcutActionToggleFollowRemoteWindow,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -580,6 +749,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Zoom cursor')),
value: peerState.value,
actionId: kShortcutActionToggleZoomCursor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -588,7 +758,8 @@ Future<List<TToggleMenu>> toolbarCursor(
},
));
}
return v;
return _registerToggleMenuShortcuts(ffi, v,
ownedActionIds: _kToolbarCursorActionIds);
}
Future<List<TToggleMenu>> toolbarDisplayToggle(
@@ -604,6 +775,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
final option = 'show-quality-monitor';
v.add(TToggleMenu(
value: bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option),
actionId: kShortcutActionToggleQualityMonitor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -617,6 +789,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleMute,
onChanged: (value) {
if (value == null) return;
bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -641,6 +814,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
sessionId: sessionId, arg: kOptionEnableFileCopyPaste);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleEnableFileCopyPaste,
onChanged: enabled
? (value) {
if (value == null) return;
@@ -659,6 +833,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
if (ffiModel.viewOnly) value = true;
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleDisableClipboard,
onChanged: enabled
? (value) {
if (value == null) return;
@@ -675,6 +850,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleLockAfterSessionEnd,
onChanged: enabled
? (value) {
if (value == null) return;
@@ -725,6 +901,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleTrueColor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -749,7 +926,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
},
child: Text(translate('View Mode'))));
}
return v;
return _registerToggleMenuShortcuts(ffi, v,
ownedActionIds: _kToolbarDisplayToggleActionIds);
}
var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1));
@@ -759,18 +937,9 @@ List<TToggleMenu> 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<void> Function(SessionID sid, String opt) toggleFunc) {
final enabled =
!ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty);
final enabled = !ffi.ffiModel.viewOnly;
return TToggleMenu(
value: privacyModeState.isNotEmpty,
onChanged: enabled
@@ -819,29 +988,18 @@ List<TToggleMenu> toolbarPrivacyMode(
})
];
} else {
final visibleImpls = hasPrivacyModePermission
? privacyModeImpls
: privacyModeImpls.where((e) {
final implKey = (e as List<dynamic>)[0] as String;
return privacyModeState.value == implKey;
}).toList();
return visibleImpls.map((e) {
return privacyModeImpls.map((e) {
final implKey = (e as List<dynamic>)[0] as String;
final implName = (e)[1] as String;
final enabled = !ffiModel.viewOnly &&
(hasPrivacyModePermission || privacyModeState.value == implKey);
return TToggleMenu(
child: Text(translate(implName)),
value: privacyModeState.value == implKey,
onChanged: enabled
? (value) {
if (value == null) return;
if (value && !hasPrivacyModePermission) return;
togglePrivacyModeTime = DateTime.now();
bind.sessionTogglePrivacyMode(
sessionId: sessionId, implKey: implKey, on: value);
}
: null);
onChanged: (value) {
if (value == null) return;
togglePrivacyModeTime = DateTime.now();
bind.sessionTogglePrivacyMode(
sessionId: sessionId, implKey: implKey, on: value);
});
}).toList();
}
}
@@ -868,6 +1026,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
final enabled = !ffi.ffiModel.viewOnly;
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleSwapCtrlCmd,
onChanged: enabled ? onChanged : null,
child: Text(translate('Swap control-command key'))));
}
@@ -933,10 +1092,27 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
final enabled = !ffi.ffiModel.viewOnly;
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleSwapLeftRightMouse,
onChanged: enabled ? onChanged : null,
child: Text(translate('swap-left-right-mouse'))));
}
return v;
return _registerToggleMenuShortcuts(ffi, v,
ownedActionIds: _kToolbarKeyboardToggleActionIds);
}
/// Drive each toolbar helper for its registration side effect, so a shortcut
/// fires from the first keystroke without needing the user to open the
/// matching submenu. Mobile gets `toolbarKeyboardToggles` via
/// `toolbarDisplayToggle`'s `isMobile` branch — calling it explicitly there
/// would double-register.
void registerToolbarShortcuts(BuildContext context, String id, FFI ffi) {
if (isDesktop) toolbarKeyboardToggles(ffi);
unawaited(toolbarCursor(context, id, ffi));
unawaited(toolbarDisplayToggle(context, id, ffi));
unawaited(toolbarViewStyle(context, id, ffi));
unawaited(toolbarImageQuality(context, id, ffi));
unawaited(toolbarCodec(context, id, ffi));
toolbarPrivacyMode(PrivacyModeState.find(id), context, id, ffi);
}
bool showVirtualDisplayMenu(FFI ffi) {

View File

@@ -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;
@@ -114,9 +116,6 @@ const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
const String kOptionEnablePermChangeInAcceptWindow =
"enable-perm-change-in-accept-window";
const String kOptionAllowRemoteConfigModification =
"allow-remote-config-modification";
const String kOptionVerificationMethod = "verification-method";

View File

@@ -0,0 +1,63 @@
// 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<DesktopKeyboardShortcutsPage> createState() =>
_DesktopKeyboardShortcutsPageState();
}
class _DesktopKeyboardShortcutsPageState
extends State<DesktopKeyboardShortcutsPage> {
final GlobalKey<KeyboardShortcutsPageBodyState> _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,
// Desktop's General settings tab already exposes the Enable +
// Pass-through checkboxes (it's the only entry point to this page),
// so we hide the duplicates here. Mobile shells keep the default
// (true) because their entry tile doesn't carry the toggles.
showMasterToggles: false,
),
);
}
}

View File

@@ -10,12 +10,15 @@ 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/common/widgets/keyboard_shortcuts/page_body.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 +424,50 @@ 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(() {});
},
trailing: const InfoTooltipIcon(tipKey: 'shortcut-passthrough-tip'),
),
_ShortcutsConfigureRow(),
],
]);
});
}
Widget theme() {
final current = MyTheme.getThemeModePreference().toShortString();
onChanged(String value) async {
@@ -1062,10 +1104,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
_OptionCheckBox(context, 'Enable blocking user input',
kOptionEnableBlockInput,
enabled: enabled, fakeValue: fakeValue),
if (bind.mainSupportedPrivacyModeImpls() != '[]')
_OptionCheckBox(
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable remote configuration modification',
kOptionAllowRemoteConfigModification,
enabled: enabled, fakeValue: fakeValue),
@@ -2496,6 +2534,8 @@ Widget _OptionCheckBox(
bool isServer = true,
bool Function()? optGetter,
Future<void> Function(String, bool)? optSetter,
// Optional widget rendered between the label and the trailing space.
Widget? trailing,
}) {
getOpt() => optGetter != null
? optGetter()
@@ -2539,11 +2579,23 @@ Widget _OptionCheckBox(
offstage: !ref.value || checkedIcon == null,
child: checkedIcon?.marginOnly(right: 5),
),
Expanded(
// Without `trailing`, keep the original Expanded(Text) layout.
if (trailing == null)
Expanded(
child: Text(
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
else ...[
Flexible(
child: Text(
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
),
),
trailing,
const Spacer(),
],
],
),
).marginOnly(left: _kCheckBoxLeftMargin),
@@ -2950,6 +3002,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

View File

@@ -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,18 @@ class _RemotePageState extends State<RemotePage>
_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);
registerSessionShortcutActions(_ffi,
tabController: widget.tabController,
toolbarState: widget.toolbarState);
registerToolbarShortcuts(context, widget.id, _ffi);
}
});
_ffi.canvasModel.initializeEdgeScrollFallback(this);
_ffi.start(

View File

@@ -610,24 +610,19 @@ class _PrivilegeBoard extends StatefulWidget {
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
late final client = widget.client;
Widget buildPermissionIcon(bool enabled, IconData iconData,
Function(bool)? onTap, String tooltipText,
{required bool canModify}) {
Function(bool)? onTap, String tooltipText) {
return Tooltip(
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
waitDuration: Duration.zero,
child: Container(
decoration: BoxDecoration(
color: enabled
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
: Colors.grey[700],
color: enabled ? MyTheme.accent : Colors.grey[700],
borderRadius: BorderRadius.circular(10.0),
),
padding: EdgeInsets.all(8.0),
child: InkWell(
onTap: canModify
? () =>
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
: null,
onTap: () =>
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -648,9 +643,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
Widget build(BuildContext context) {
final crossAxisCount = 4;
final spacing = 10.0;
final canModifyPermission =
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
'N';
return Container(
width: double.infinity,
height: 160.0,
@@ -697,7 +689,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@@ -712,7 +703,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
canModify: canModifyPermission,
),
]
: [
@@ -729,7 +719,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable keyboard/mouse'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.clipboard,
@@ -744,7 +733,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable clipboard'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.audio,
@@ -759,7 +747,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.file,
@@ -774,7 +761,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable file copy and paste'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.restart,
@@ -789,7 +775,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable remote restart'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@@ -804,7 +789,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
canModify: canModifyPermission,
),
// only windows support block input
if (isWindows)
@@ -821,23 +805,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable blocking user input'),
canModify: canModifyPermission,
),
if (bind.mainSupportedPrivacyModeImpls() != '[]')
buildPermissionIcon(
client.privacyMode,
Icons.visibility_off,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "privacy_mode",
enabled: enabled);
setState(() {
client.privacyMode = enabled;
});
},
translate('Enable privacy mode'),
canModify: canModifyPermission,
)
],
),

View File

@@ -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';
@@ -610,8 +611,9 @@ class _MonitorMenu extends StatelessWidget {
tooltip: isMulti
? ''
: isAllMonitors
? 'all monitors'
: '#${i + 1} monitor',
? translate('All monitors')
: translate('Monitor #{}')
.replaceAll('{}', '${i + 1}'),
hMargin: isMulti ? null : 6,
vMargin: isMulti ? null : 12,
topLevel: false,
@@ -763,8 +765,35 @@ 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),
Flexible(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
hint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
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);
@@ -996,10 +1025,10 @@ class _DisplayMenuState extends State<_DisplayMenu> {
toggles(),
];
// privacy mode
final privacyModeState = PrivacyModeState.find(id);
if (ffi.connType == ConnType.defaultConn &&
(pi.features.privacyMode || privacyModeState.isNotEmpty) &&
(ffiModel.keyboard || privacyModeState.isNotEmpty)) {
ffiModel.keyboard &&
pi.features.privacyMode) {
final privacyModeState = PrivacyModeState.find(id);
final privacyModeList =
toolbarPrivacyMode(privacyModeState, context, id, ffi);
if (privacyModeList.length == 1) {

View File

@@ -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<MobileKeyboardShortcutsPage> createState() =>
_MobileKeyboardShortcutsPageState();
}
class _MobileKeyboardShortcutsPageState
extends State<MobileKeyboardShortcutsPage> {
final GlobalKey<KeyboardShortcutsPageBodyState> _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),
),
),
],
),
);
}
}

View File

@@ -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<RemotePage> 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 will
// log a no-handler debug line if a user binds one.
registerSessionShortcutActions(gFFI);
registerToolbarShortcuts(context, widget.id, gFFI);
}
});
WidgetsBinding.instance.addObserver(this);
}
@@ -1183,8 +1196,7 @@ void showOptions(
List<TToggleMenu> privacyModeList = [];
// privacy mode
final privacyModeState = PrivacyModeState.find(id);
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
privacyModeState.isNotEmpty) {
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
if (privacyModeList.length == 1) {
displayToggles.add(privacyModeList[0]);

View File

@@ -583,16 +583,9 @@ class _PermissionCheckerState extends State<PermissionChecker> {
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final hideStopService = isAndroid &&
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
final allowPermChangeInAcceptWindow = option2bool(
kOptionEnablePermChangeInAcceptWindow,
bind.mainGetBuildinOption(
key: kOptionEnablePermChangeInAcceptWindow,
));
final permissionChangeLocked = isAndroid &&
serverModel.clients.any((c) => !c.disconnected) &&
!allowPermChangeInAcceptWindow;
final hideStopService =
isAndroid &&
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
return PaddingCard(
title: translate("Permissions"),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
@@ -615,21 +608,13 @@ class _PermissionCheckerState extends State<PermissionChecker> {
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
? () => showScamWarning(context, serverModel)
: serverModel.toggleService),
PermissionRow(
translate("Input Control"),
serverModel.inputOk,
serverModel.toggleInput,
),
PermissionRow(
translate("Transfer file"),
serverModel.fileOk,
serverModel.toggleFile,
enabled: !permissionChangeLocked,
),
PermissionRow(translate("Input Control"), serverModel.inputOk,
serverModel.toggleInput),
PermissionRow(translate("Transfer file"), serverModel.fileOk,
serverModel.toggleFile),
hasAudioPermission
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
serverModel.toggleAudio,
enabled: !permissionChangeLocked)
serverModel.toggleAudio)
: Row(children: [
Icon(Icons.info_outline).marginOnly(right: 15),
Expanded(
@@ -638,25 +623,19 @@ class _PermissionCheckerState extends State<PermissionChecker> {
style: const TextStyle(color: MyTheme.darkGray),
))
]),
PermissionRow(
translate("Enable clipboard"),
serverModel.clipboardOk,
serverModel.toggleClipboard,
enabled: !permissionChangeLocked,
),
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
serverModel.toggleClipboard),
]));
}
}
class PermissionRow extends StatelessWidget {
const PermissionRow(this.name, this.isOk, this.onPressed,
{Key? key, this.enabled = true})
const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
: super(key: key);
final String name;
final bool isOk;
final VoidCallback onPressed;
final bool enabled;
@override
Widget build(BuildContext context) {
@@ -665,11 +644,9 @@ class PermissionRow extends StatelessWidget {
contentPadding: EdgeInsets.all(0),
title: Text(name),
value: isOk,
onChanged: enabled
? (bool value) {
onPressed();
}
: null);
onChanged: (bool value) {
onPressed();
});
}
}

View File

@@ -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,23 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
showThemeSettings(gFFI.dialogManager);
},
),
if (!disabledSettings)
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 +1371,4 @@ SettingsTile _getPopupDialogRadioEntry({
),
);
}

View File

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

View File

@@ -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,
@@ -3925,6 +3933,7 @@ class FFI {
ffiModel.pi.currentDisplay);
}
imageModel.callbacksOnFirstImage.clear();
shortcutModel.clear();
await imageModel.update(null);
cursorModel.clear();
ffiModel.clear();

View File

@@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier {
}
toggleAudio() async {
if (clients.any((c) => !c.disconnected)) {
if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
@@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
}
toggleFile() async {
if (clients.any((c) => !c.disconnected)) {
if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_fileOk &&
@@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
}
toggleInput() async {
if (clients.any((c) => !c.disconnected)) {
if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (_inputOk) {
@@ -549,19 +549,10 @@ class ServerModel with ChangeNotifier {
if (index < 0) {
_clients.add(client);
} else {
if (_clients[index].authorized) {
_clients[index].privacyMode = client.privacyMode;
notifyListeners();
return;
}
_clients[index].authorized = true;
_clients[index].privacyMode = client.privacyMode;
}
} else {
final index = _clients.indexWhere((c) => c.id == client.id);
if (index >= 0) {
_clients[index].privacyMode = client.privacyMode;
notifyListeners();
if (_clients.any((c) => c.id == client.id)) {
return;
}
_clients.add(client);
@@ -827,7 +818,6 @@ class Client {
bool restart = false;
bool recording = false;
bool blockInput = false;
bool privacyMode = false;
bool disconnected = false;
bool fromSwitch = false;
bool inVoiceCall = false;
@@ -856,7 +846,6 @@ class Client {
restart = json['restart'];
recording = json['recording'];
blockInput = json['block_input'];
privacyMode = json['privacy_mode'] ?? privacyMode;
disconnected = json['disconnected'];
fromSwitch = json['from_switch'];
inVoiceCall = json['in_voice_call'];
@@ -881,7 +870,6 @@ class Client {
data['restart'] = restart;
data['recording'] = recording;
data['block_input'] = blockInput;
data['privacy_mode'] = privacyMode;
data['disconnected'] = disconnected;
data['from_switch'] = fromSwitch;
data['in_voice_call'] = inVoiceCall;

View File

@@ -0,0 +1,545 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../common.dart';
import '../common/shared_state.dart' show PrivacyModeState;
import '../common/widgets/dialog.dart'
show desktopTryShowTabAuditDialogCloseCancelled;
import '../common/widgets/keyboard_shortcuts/shortcut_utils.dart';
import '../consts.dart';
import '../desktop/widgets/remote_toolbar.dart' show ToolbarState;
import 'chat_model.dart' show VoiceCallStatus;
import '../desktop/widgets/tabbar_widget.dart' show DesktopTabController;
import '../models/model.dart';
import '../models/platform_model.dart';
import '../models/state_model.dart';
typedef ShortcutCallback = FutureOr<void> Function();
/// 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<FFI> parent;
final Map<String, ShortcutCallback> _callbacks = {};
ShortcutModel(this.parent);
/// Called by toolbar / menu builders to register what to do when the
/// matched shortcut fires.
void register(String actionId, ShortcutCallback callback) {
_callbacks[actionId] = callback;
}
void unregister(String actionId) {
_callbacks.remove(actionId);
}
void clear() {
_callbacks.clear();
}
/// 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) {
unawaited(Future.sync(cb).catchError((e, st) {
debugPrint(
'shortcut_triggered: handler failed for $actionId: $e\n$st');
}));
} else {
debugPrint('shortcut_triggered: no handler for $actionId');
}
}
/// Read the bindings JSON from LocalConfig.
static List<Map<String, dynamic>> readBindings() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return [];
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
return shortcutBindingMapsFrom(parsed['bindings']);
} catch (_) {
return [];
}
}
static bool isEnabled() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return false;
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
return parsed['enabled'] == true;
} catch (_) {
return false;
}
}
static bool isPassThrough() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return false;
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
return parsed['pass_through'] == true;
} catch (_) {
return false;
}
}
/// Persistent companion to [isEnabled]: when on, the matchers return early
/// and every keystroke flows through to the remote (i.e. all bindings are
/// suspended). Stored in the same JSON blob so a single reload refreshes
/// both flags on every active matcher.
static Future<void> setPassThrough(bool v) async {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
Map<String, dynamic> json = {};
if (raw.isNotEmpty) {
try {
json = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
json = {};
}
}
json['pass_through'] = v;
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));
bind.mainReloadKeyboardShortcuts();
}
/// Flip the master `enabled` flag and persist. On the first enable we seed
/// the default bindings so common combos work out of the box; otherwise we
/// preserve whatever the user already has. Refreshes the matcher cache so
/// the change takes effect immediately (Rust on native, JS via the bridge
/// on Web).
static Future<void> setEnabled(bool v) async {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
Map<String, dynamic> json = {};
if (raw.isNotEmpty) {
try {
json = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
json = {};
}
}
json['enabled'] = v;
final list = shortcutBindingMapsFrom(json['bindings']);
if (v && list.isEmpty) {
json['bindings'] = filterDefaultBindingsForPlatform(
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
currentPlatformCapabilities(),
);
} else {
json['bindings'] = list;
}
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));
bind.mainReloadKeyboardShortcuts();
}
/// Single source of truth for the per-platform "is this shortcut applicable"
/// decisions. Both [setEnabled]'s default-seeding pass and the configuration
/// page's reset / list-rendering paths read from here, so the seed list and
/// the visible action list can never disagree on which platform a given
/// action belongs to.
///
/// Capability rationale:
/// * Fullscreen / Toolbar / Pin / View Mode: rendered wherever the
/// desktop layout applies (native desktop + Web). Native mobile is
/// permanently full-screen and doesn't have a desktop-style toolbar.
/// * Screenshot / Switch Sides: native desktop only. The Web bridge
/// throws UnimplementedError for `sessionTakeScreenshot`; mobile
/// toolbars don't surface either action.
/// * Tab navigation / Close Tab: only native desktop ships
/// `DesktopTabController`; Web's `RemotePage` is invoked without one.
/// * Recording: native desktop has the `_RecordMenu` widget +
/// `registerSessionShortcutActions` registration; native Android has
/// the `toolbarControls` entry; iOS short-circuits inside
/// `recordingModel.toggle()`; Web has no implementation.
/// * Reset Canvas: only the mobile toolbar builds the menu entry
/// (`isDefaultConn && isMobile` in `toolbarControls`).
/// * Input Source: Web only ships a single source so toggling is a
/// no-op; the toolbar menu hides itself when fewer than 2 sources are
/// advertised.
/// * Voice Call: Web bridge throws `UnimplementedError` for both
/// `sessionRequestVoiceCall` and `sessionCloseVoiceCall`.
static ShortcutPlatformCapabilities currentPlatformCapabilities() {
final desktopLayout = isDesktop || isWeb;
return ShortcutPlatformCapabilities(
includeFullscreenShortcut: desktopLayout,
includeScreenshotShortcut: isDesktop,
includeTabShortcuts: isDesktop,
includeToolbarShortcut: desktopLayout,
includeCloseTabShortcut: isDesktop,
includeSwitchSidesShortcut: isDesktop,
includeRecordingShortcut: !isWeb && !isIOS,
includeResetCanvasShortcut: isMobile,
includePinToolbarShortcut: desktopLayout,
includeViewModeShortcut: desktopLayout,
includeInputSourceShortcut: !isWeb,
includeVoiceCallShortcut: !isWeb,
);
}
}
/// 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.
///
/// We register unconditionally — even when shortcuts are master-disabled —
/// because the matcher (Rust + JS) gates dispatch via the `enabled` flag,
/// so registered closures are functionally invisible until the user flips
/// shortcuts on. This keeps the wiring simple (no rebind callbacks across
/// sessions) and lets the user toggle shortcuts mid-session without
/// reconnecting.
///
/// [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,
ToolbarState? toolbarState,
}) {
final sessionId = ffi.sessionId;
// Note on disposal: every closure registered below captures `ffi` via
// closure environment, so the FFI object stays alive for the duration of
// the closure's execution — even across awaits, even if the session is
// closed mid-execution. We therefore don't add per-closure liveness
// guards: a `WeakReference<FFI>` check would never go null while the
// closure is on the call stack, and the underlying `bind.session*` /
// model setters tolerate stale-session calls (they no-op on torn-down
// sessions). ShortcutModel.onTriggered's existing entry guard
// (`_callbacks` lookup returning null after disposal) is the actual
// liveness gate.
// Toggle Fullscreen — available wherever the desktop layout renders
// (native desktop + every Web browser, since Web uses the desktop
// RemotePage). `stateGlobal.setFullscreen` handles native window vs.
// browser fullscreen. Native mobile is permanently full-screen, so the
// action is intentionally not registered there.
if (isDesktop || isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () {
stateGlobal.setFullscreen(!stateGlobal.fullscreen.value);
});
}
// Toggle Recording — desktop only here. Mobile already wires this through
// `toolbarControls` (which adds a recording entry on `!(isDesktop||isWeb)`),
// but the desktop toolbar uses a separate `_RecordMenu` widget that has no
// `actionId`. Without this explicit registration a desktop user could bind
// Toggle Recording in settings and the press would have no handler.
// `recordingModel.toggle()` itself short-circuits on iOS and on sessions
// without recording permission.
if (isDesktop) {
ffi.shortcutModel.register(kShortcutActionToggleRecording, () {
ffi.recordingModel.toggle();
});
}
// Switch Display Next / Prev — requires the peer to have at least 2
// displays. From the "All displays" merged view, Next jumps to display 0
// and Prev to the last display, so the user can always escape the merged
// view via these shortcuts.
void switchDisplayBy(int delta) {
final pi = ffi.ffiModel.pi;
final count = pi.displays.length;
if (count <= 1) return;
final current = pi.currentDisplay;
final int next;
if (current == kAllDisplayValue) {
next = delta > 0 ? 0 : count - 1;
} else {
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 to all-monitors view — mirrors the toolbar Monitor menu's
// "all monitors" button (only built when peer has >1 display). Not a
// toggle: the toolbar button just sets the merged view; another action
// (Switch to next/previous display, or another monitor button) takes
// you back to a single display.
//
// Use `openMonitorInTheSameTab(kAllDisplayValue, ...)` rather than calling
// `sessionSwitchDisplay` with `[kAllDisplayValue]` directly — the toolbar
// path treats `kAllDisplayValue` as a UI sentinel and expands it to the
// real display index list (`[0, 1, ...]`) before sending, then updates
// local FfiModel state. Sending `[-1]` raw produces a wire value the
// remote can't act on and skips the local state update, so the merged
// view never engages.
ffi.shortcutModel.register(kShortcutActionSwitchDisplayAll, () {
final pi = ffi.ffiModel.pi;
if (pi.displays.length <= 1) return;
if (pi.currentDisplay == kAllDisplayValue) return;
openMonitorInTheSameTab(kAllDisplayValue, ffi, pi);
});
// Switch tab next / prev — 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. We
// intentionally don't expose positional ("Switch to tab N") shortcuts:
// counting tabs in a long list is impractical, and AnyDesk / Chrome
// standard practice is to favour next/prev navigation.
if (tabController != null) {
void switchTabBy(int delta) {
final tabs = tabController.state.value.tabs;
if (tabs.length <= 1) return;
final cur = tabs.indexWhere((t) => t.key == ffi.id);
if (cur < 0) return;
final next = (cur + delta + tabs.length) % tabs.length;
tabController.jumpTo(next);
}
ffi.shortcutModel
.register(kShortcutActionSwitchTabNext, () => switchTabBy(1));
ffi.shortcutModel
.register(kShortcutActionSwitchTabPrev, () => switchTabBy(-1));
// Close Tab — desktop only. Mirrors the tab right-click "Close" entry,
// including the audit-log confirmation dialog so a shortcut close goes
// through the same path as a menu close.
ffi.shortcutModel.register(kShortcutActionCloseTab, () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: ffi.id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(ffi.id);
});
}
// Toggle Toolbar — desktop only. ToolbarState is window/session-scoped,
// owned by the RemotePage that hosts this session.
if (toolbarState != null) {
ffi.shortcutModel.register(kShortcutActionToggleToolbar, () {
toolbarState.switchHide(sessionId);
});
ffi.shortcutModel.register(kShortcutActionPinToolbar, () {
toolbarState.switchPin();
});
}
// Toggle Chat overlay (open/close the chat panel for this session).
// _ChatMenu is a standalone toolbar icon — not part of any toolbar
// helper that returns a TToggleMenu list — so its handler is wired
// here rather than picked up by helper auto-register.
ffi.shortcutModel.register(kShortcutActionToggleChat, () {
ffi.chatModel.toggleChatOverlay();
});
// Toggle Voice Call — start when idle, hang up when active. Mirrors the
// toolbar's `_VoiceCallMenu` state-driven button. Web bridge throws
// UnimplementedError on both sessionRequestVoiceCall and
// sessionCloseVoiceCall, so we don't register on web.
if (!isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleVoiceCall, () {
final status = ffi.chatModel.voiceCallStatus.value;
if (status == VoiceCallStatus.connected ||
status == VoiceCallStatus.waitingForResponse) {
bind.sessionCloseVoiceCall(sessionId: sessionId);
} else {
bind.sessionRequestVoiceCall(sessionId: sessionId);
}
});
}
// ── Inline _KeyboardMenu items + actions with no toolbar TToggleMenu/TRadioMenu ─
// The toolbar's TToggleMenu / TRadioMenu helpers (toolbarDisplayToggle,
// toolbarCursor, toolbarKeyboardToggles, toolbarCodec, toolbarPrivacyMode,
// toolbarViewStyle, toolbarImageQuality) auto-register their tagged entries
// from the bottom of each helper. The handlers below cover what those
// helpers DON'T own:
// * Show my cursor / Keyboard mode (Map/Translate/Legacy) / View Only
// (desktop) — built as widgets directly in `_KeyboardMenu`, not as
// TToggleMenu lists. (Mobile View Only IS in toolbarDisplayToggle and
// auto-registers; the desktop session-start handler below registers
// first and the helper's auto-register on mobile takes over after its
// unawaited future resolves.)
// * Plug out all virtual displays — built in `getVirtualDisplayMenuChildren`
// as a MenuButton, not a TToggleMenu.
// * Toggle Input Source — cycle action; the toolbar exposes per-source
// radios but no single "cycle to next source" entry.
// Show my cursor — toolbar (`_KeyboardMenu.showMyCursor`) pushes the new
// value into FfiModel.setShowMyCursor and auto-enables view-only when the
// toggle goes on, so the user can never control the remote with their own
// cursor visible.
ffi.shortcutModel.register(kShortcutActionToggleShowMyCursor, () async {
await bind.sessionToggleOption(
sessionId: sessionId, value: kOptionToggleShowMyCursor);
final showMyCursor = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
false;
ffi.ffiModel.setShowMyCursor(showMyCursor);
if (showMyCursor && !ffi.ffiModel.viewOnly) {
await bind.sessionToggleOption(
sessionId: sessionId, value: kOptionToggleViewOnly);
final viewOnly = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
false;
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
}
});
// Keyboard mode (Map / Translate / Legacy). Mirrors the radio buttons in
// `_KeyboardMenu.keyboardMode()` (built as RdoMenuButton, not TRadioMenu).
void registerKeyboardMode(String actionId, String mode) {
ffi.shortcutModel.register(actionId, () async {
await bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
await ffi.inputModel.updateKeyboardMode();
});
}
registerKeyboardMode(kShortcutActionKeyboardModeMap, kKeyMapMode);
registerKeyboardMode(kShortcutActionKeyboardModeTranslate, kKeyTranslateMode);
registerKeyboardMode(kShortcutActionKeyboardModeLegacy, kKeyLegacyMode);
// Plug out all virtual displays (Windows + IDD only). Mirrors the toolbar's
// "Plug out all" button — present in both IDD modes (RustDesk + Amyuni),
// built as a MenuButton inside `getVirtualDisplayMenuChildren`.
ffi.shortcutModel.register(kShortcutActionPlugOutAllVirtualDisplays, () {
bind.sessionToggleVirtualDisplay(
sessionId: sessionId,
index: kAllVirtualDisplay,
on: false,
);
});
// Privacy mode 1 / 2 — fallback handlers for the single-impl and null-impls
// branches of `toolbarPrivacyMode`. The multi-impl branch tags each entry
// with the matching actionId and `_registerToggleMenuShortcuts` overrides
// these closures with the toolbar's own onChanged. But when the peer only
// advertises a single impl (older Linux peers, certain platform configs)
// toolbarPrivacyMode returns a `getDefaultMenu` entry without an actionId,
// so the auto-register pass skips it — these fallbacks are what actually
// wire the shortcut in that case.
String? findPrivacyImpl(String nameKey) {
final impls = ffi.ffiModel.pi
.platformAdditions[kPlatformAdditionsSupportedPrivacyModeImpl]
as List<dynamic>?;
if (impls == null) return null;
for (final e in impls) {
if (e is List && e.length >= 2 && e[1] == nameKey) return e[0] as String;
}
return null;
}
// Match the multi-impl branch of `toolbarPrivacyMode`: turn this impl on iff
// the active impl isn't already this one. Comparing `.value == implKey`
// (rather than `.value.isEmpty`) means pressing the mode-1 shortcut while
// mode 2 is on correctly turns mode 1 ON, instead of misreading the
// "any-mode-active" state as "this-mode-active" and toggling OFF.
ffi.shortcutModel.register(kShortcutActionPrivacyMode1, () {
final implKey = findPrivacyImpl('privacy_mode_impl_mag_tip');
if (implKey == null) return;
bind.sessionTogglePrivacyMode(
sessionId: sessionId,
implKey: implKey,
on: PrivacyModeState.find(ffi.id).value != implKey,
);
});
ffi.shortcutModel.register(kShortcutActionPrivacyMode2, () {
final implKey = findPrivacyImpl('privacy_mode_impl_virtual_display_tip');
if (implKey == null) return;
bind.sessionTogglePrivacyMode(
sessionId: sessionId,
implKey: implKey,
on: PrivacyModeState.find(ffi.id).value != implKey,
);
});
// View Only — desktop toolbar exposes this inline in `_KeyboardMenu.viewMode`
// (mobile is in toolbarDisplayToggle and goes through helper auto-register).
// Mirrors the desktop callback: toggle + sync FfiModel.viewOnly +
// FfiModel.showMyCursor (the toolbar keeps these in step).
ffi.shortcutModel.register(kShortcutActionToggleViewOnly, () async {
await bind.sessionToggleOption(
sessionId: sessionId, value: kOptionToggleViewOnly);
final viewOnly = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
false;
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
final showMyCursor = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
false;
ffi.ffiModel.setShowMyCursor(showMyCursor);
});
// Toggle Reverse mouse wheel — read current 'Y'/'N' (falling back to user
// default), flip, write back.
ffi.shortcutModel.register(kShortcutActionToggleReverseMouseWheel, () async {
var cur = bind.sessionGetReverseMouseWheelSync(sessionId: sessionId) ?? '';
if (cur == '') {
cur = bind.mainGetUserDefaultOption(key: kKeyReverseMouseWheel);
}
final next = cur == 'Y' ? 'N' : 'Y';
await bind.sessionSetReverseMouseWheel(sessionId: sessionId, value: next);
});
// Toggle Relative mouse mode (gaming mode). Desktop only.
if (isDesktop && !isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleRelativeMouseMode, () {
ffi.inputModel.toggleRelativeMouseMode();
});
}
// Toggle Input Source — flips between the available keyboard-event capture
// backends (e.g. JS vs Flutter on desktop). Mirrors the radio menu in
// remote_toolbar.dart::inputSource(); when fewer than 2 sources are
// available the menu hides itself, so this handler is a no-op too.
// Useful for accessibility: screen-reader users sometimes need to swap
// sources to regain control of the local keyboard (discussion #1933).
// Web only ships a single source, so we don't register on web.
if (!isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleInputSource, () async {
final raw = bind.mainSupportedInputSource();
if (raw.isEmpty) return;
final List<dynamic> list;
try {
list = jsonDecode(raw) as List<dynamic>;
} catch (_) {
return;
}
if (list.length < 2) return;
final ids = list
.map((e) => (e is List && e.isNotEmpty) ? e[0] as String : '')
.where((s) => s.isNotEmpty)
.toList();
if (ids.length < 2) return;
final current = stateGlobal.getInputSource();
final idx = ids.indexOf(current);
final next = ids[(idx < 0 ? 0 : idx + 1) % ids.length];
await stateGlobal.setInputSource(sessionId, next);
await ffi.ffiModel.checkDesktopKeyboardMode();
await ffi.inputModel.updateKeyboardMode();
});
}
}

View File

@@ -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<void> 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();
}
@@ -1729,7 +1754,7 @@ class RustdeskImpl {
}
String mainSupportedPrivacyModeImpls({dynamic hint}) {
return '[]';
throw UnimplementedError("mainSupportedPrivacyModeImpls");
}
String mainSupportedInputSource({dynamic hint}) {

View File

@@ -0,0 +1,11 @@
[
{"action": "send_ctrl_alt_del", "mods": ["primary", "alt", "shift"], "key": "delete"},
{"action": "toggle_fullscreen", "mods": ["primary", "alt", "shift"], "key": "enter"},
{"action": "switch_display_next", "mods": ["primary", "alt", "shift"], "key": "arrow_right"},
{"action": "switch_display_prev", "mods": ["primary", "alt", "shift"], "key": "arrow_left"},
{"action": "screenshot", "mods": ["primary", "alt", "shift"], "key": "p"},
{"action": "toggle_show_remote_cursor", "mods": ["primary", "alt", "shift"], "key": "m"},
{"action": "toggle_mute", "mods": ["primary", "alt", "shift"], "key": "s"},
{"action": "toggle_block_input", "mods": ["primary", "alt", "shift"], "key": "i"},
{"action": "toggle_chat", "mods": ["primary", "alt", "shift"], "key": "c"}
]

View File

@@ -0,0 +1,11 @@
[
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"digit0", "digit1", "digit2", "digit3", "digit4",
"digit5", "digit6", "digit7", "digit8", "digit9",
"f1", "f2", "f3", "f4", "f5", "f6",
"f7", "f8", "f9", "f10", "f11", "f12",
"delete", "backspace", "tab", "space", "enter",
"arrow_left", "arrow_right", "arrow_up", "arrow_down",
"home", "end", "page_up", "page_down", "insert"
]

View File

@@ -0,0 +1,426 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_actions.dart';
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_constants.dart';
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_utils.dart';
ShortcutPlatformCapabilities capabilities({
bool includeFullscreenShortcut = true,
bool includeScreenshotShortcut = true,
bool includeTabShortcuts = true,
bool includeToolbarShortcut = true,
bool includeCloseTabShortcut = true,
bool includeSwitchSidesShortcut = true,
bool includeRecordingShortcut = true,
bool includeResetCanvasShortcut = true,
bool includePinToolbarShortcut = true,
bool includeViewModeShortcut = true,
bool includeInputSourceShortcut = true,
bool includeVoiceCallShortcut = true,
}) {
return ShortcutPlatformCapabilities(
includeFullscreenShortcut: includeFullscreenShortcut,
includeScreenshotShortcut: includeScreenshotShortcut,
includeTabShortcuts: includeTabShortcuts,
includeToolbarShortcut: includeToolbarShortcut,
includeCloseTabShortcut: includeCloseTabShortcut,
includeSwitchSidesShortcut: includeSwitchSidesShortcut,
includeRecordingShortcut: includeRecordingShortcut,
includeResetCanvasShortcut: includeResetCanvasShortcut,
includePinToolbarShortcut: includePinToolbarShortcut,
includeViewModeShortcut: includeViewModeShortcut,
includeInputSourceShortcut: includeInputSourceShortcut,
includeVoiceCallShortcut: includeVoiceCallShortcut,
);
}
void main() {
test('kDefaultShortcutBindings matches fixture', () {
// The fixture is the cross-language source of truth for default
// bindings. Rust has its own parity test against the same file
// (`default_bindings_match_fixture_json` in src/keyboard/shortcuts.rs),
// so a drift on either side breaks CI.
final fixturePath = 'test/fixtures/default_keyboard_shortcuts.json';
final fixture =
jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>;
expect(kDefaultShortcutBindings, equals(fixture),
reason: 'kDefaultShortcutBindings drifted from $fixturePath — update '
'shortcut_constants.dart, the fixture, and Rust default_bindings() '
'together');
});
test('save order preserves macOS control modifier', () {
expect(canonicalShortcutModsForSave({'ctrl'}), ['ctrl']);
expect(canonicalShortcutModsForSave({'shift', 'ctrl', 'primary', 'alt'}),
['primary', 'ctrl', 'alt', 'shift']);
});
test('non-desktop defaults exclude desktop-only and tab shortcuts', () {
final defaults = [
{
'action': kShortcutActionSendCtrlAltDel,
'mods': ['primary', 'alt', 'shift'],
'key': 'delete',
},
{
'action': kShortcutActionToggleFullscreen,
'mods': ['primary', 'alt', 'shift'],
'key': 'enter',
},
{
'action': kShortcutActionSwitchDisplayNext,
'mods': ['primary', 'alt', 'shift'],
'key': 'arrow_right',
},
{
'action': kShortcutActionScreenshot,
'mods': ['primary', 'alt', 'shift'],
'key': 'p',
},
{
'action': kShortcutActionSwitchTabNext,
'mods': ['primary', 'alt', 'shift'],
'key': 'right_bracket',
},
];
final filtered = filterDefaultBindingsForPlatform(
defaults,
capabilities(
includeFullscreenShortcut: false,
includeScreenshotShortcut: false,
includeTabShortcuts: false,
includeToolbarShortcut: false,
includeCloseTabShortcut: false,
includeSwitchSidesShortcut: false,
includeRecordingShortcut: false,
includeResetCanvasShortcut: false,
includePinToolbarShortcut: false,
includeViewModeShortcut: false,
includeInputSourceShortcut: false,
includeVoiceCallShortcut: false,
),
);
expect(filtered.map((binding) => binding['action']), [
kShortcutActionSendCtrlAltDel,
kShortcutActionSwitchDisplayNext,
]);
});
Set<String> idSet(Iterable<KeyboardShortcutActionGroup> groups) =>
{for (final e in allActionEntries(groups)) e.id};
/// Convenience: extract the children of the named group as a flat list of
/// human-readable tokens. Subgroups appear as `'group:<title>'` followed
/// by their entries, so call sites can assert on full ordering (subgroups
/// interleaved with direct items) in one expectation.
List<String> childTokens(
List<KeyboardShortcutActionGroup> groups, String titleKey) {
final group = groups.firstWhere((g) => g.titleKey == titleKey);
final out = <String>[];
for (final child in group.children) {
switch (child) {
case KeyboardShortcutActionEntry():
out.add(child.id);
case KeyboardShortcutActionSubgroup():
out.add('group:${child.titleKey}');
for (final entry in child.entries) {
out.add(' ${entry.id}');
}
}
}
return out;
}
test('filterKeyboardShortcutActionGroupsForPlatform strips desktop-only', () {
final groups = filterKeyboardShortcutActionGroupsForPlatform(
capabilities(
includeFullscreenShortcut: false,
includeScreenshotShortcut: false,
includeTabShortcuts: false,
includeToolbarShortcut: false,
includeCloseTabShortcut: false,
includeSwitchSidesShortcut: false,
// Recording / Reset Canvas are intentionally still included here —
// they have non-desktop platforms (mobile Android / mobile both).
includeRecordingShortcut: true,
includeResetCanvasShortcut: true,
includePinToolbarShortcut: false,
includeViewModeShortcut: false,
includeInputSourceShortcut: false,
includeVoiceCallShortcut: false,
),
);
final ids = idSet(groups);
// Desktop-only actions are stripped.
expect(ids, isNot(contains(kShortcutActionToggleFullscreen)));
expect(ids, isNot(contains(kShortcutActionScreenshot)));
expect(ids, isNot(contains(kShortcutActionToggleToolbar)));
expect(ids, isNot(contains(kShortcutActionCloseTab)));
expect(ids, isNot(contains(kShortcutActionSwitchSides)));
expect(ids, isNot(contains(kShortcutActionPinToolbar)));
expect(ids, isNot(contains(kShortcutActionViewModeOriginal)));
expect(ids, isNot(contains(kShortcutActionViewModeAdaptive)));
expect(ids, isNot(contains(kShortcutActionSwitchTabNext)));
expect(ids, isNot(contains(kShortcutActionSwitchTabPrev)));
// Cross-platform actions survive.
expect(ids, contains(kShortcutActionSendCtrlAltDel));
expect(ids, contains(kShortcutActionInsertLock));
expect(ids, contains(kShortcutActionRestartRemote));
expect(ids, contains(kShortcutActionSwitchDisplayNext));
expect(ids, contains(kShortcutActionToggleRecording));
expect(ids, contains(kShortcutActionResetCanvas));
expect(ids, contains(kShortcutActionToggleMute));
});
test(
'filterKeyboardShortcutActionGroupsForPlatform hides Toggle Recording on Web/iOS',
() {
final groups = filterKeyboardShortcutActionGroupsForPlatform(
capabilities(includeRecordingShortcut: false),
);
final ids = idSet(groups);
expect(ids, isNot(contains(kShortcutActionToggleRecording)));
// Other Session Control entries unaffected.
expect(ids, contains(kShortcutActionSendCtrlAltDel));
expect(ids, contains(kShortcutActionInsertLock));
});
test(
'filterKeyboardShortcutActionGroupsForPlatform keeps full set on desktop',
() {
final groups =
filterKeyboardShortcutActionGroupsForPlatform(capabilities());
expect(idSet(groups), equals(idSet(kKeyboardShortcutActionGroups)));
});
test('shortcut action groups follow toolbar menu order', () {
final groups = kKeyboardShortcutActionGroups;
// Top-level groups in toolbar order.
expect(
groups.map((g) => g.titleKey).toList(),
['Monitor', 'Control Actions', 'Display', 'Keyboard', 'Chat', 'Other'],
);
// Display: subgroups (View Mode → Image Quality → Codec → Virtual
// display) first, then direct items (cursor toggles + display toggles),
// then Privacy mode subgroup last — exactly matching `_DisplayMenu`.
expect(childTokens(groups, 'Display'), [
'group:View Mode',
' $kShortcutActionViewModeOriginal',
' $kShortcutActionViewModeAdaptive',
' $kShortcutActionViewModeCustom',
'group:Image Quality',
' $kShortcutActionImageQualityBest',
' $kShortcutActionImageQualityBalanced',
' $kShortcutActionImageQualityLow',
'group:Codec',
' $kShortcutActionCodecAuto',
' $kShortcutActionCodecVp8',
' $kShortcutActionCodecVp9',
' $kShortcutActionCodecAv1',
' $kShortcutActionCodecH264',
' $kShortcutActionCodecH265',
'group:Virtual display',
' $kShortcutActionPlugOutAllVirtualDisplays',
kShortcutActionToggleShowRemoteCursor,
kShortcutActionToggleFollowRemoteCursor,
kShortcutActionToggleFollowRemoteWindow,
kShortcutActionToggleZoomCursor,
kShortcutActionToggleQualityMonitor,
kShortcutActionToggleMute,
kShortcutActionToggleEnableFileCopyPaste,
kShortcutActionToggleDisableClipboard,
kShortcutActionToggleLockAfterSessionEnd,
kShortcutActionToggleTrueColor,
'group:Privacy mode',
' $kShortcutActionPrivacyMode1',
' $kShortcutActionPrivacyMode2',
]);
// Privacy mode is the last child under Display (matching the toolbar's
// submenu order — `_DisplayMenu` adds Privacy mode after the toggles).
final displayChildren =
groups.firstWhere((g) => g.titleKey == 'Display').children;
expect(displayChildren.last, isA<KeyboardShortcutActionSubgroup>());
expect(
(displayChildren.last as KeyboardShortcutActionSubgroup).titleKey,
'Privacy mode',
);
// Keyboard: Keyboard mode subgroup first, then direct items —
// matching `_KeyboardMenu`.
expect(childTokens(groups, 'Keyboard'), [
'group:Keyboard mode',
' $kShortcutActionKeyboardModeLegacy',
' $kShortcutActionKeyboardModeMap',
' $kShortcutActionKeyboardModeTranslate',
kShortcutActionToggleInputSource,
kShortcutActionToggleViewOnly,
kShortcutActionToggleShowMyCursor,
kShortcutActionToggleSwapCtrlCmd,
kShortcutActionToggleRelativeMouseMode,
kShortcutActionToggleReverseMouseWheel,
kShortcutActionToggleSwapLeftRightMouse,
]);
});
test('filterKeyboardShortcutActionGroupsForPlatform drops empty groups', () {
// Sanity: KeyboardShortcutActionGroup ctor still accepts a single direct
// entry as a child.
final original = [
KeyboardShortcutActionGroup('TestGroup', [
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close Tab'),
]),
];
expect(original.first.children, hasLength(1));
// With every capability flag off, groups whose items are all behind
// those flags get dropped. Display / Keyboard parent groups still carry
// cross-platform direct items so they survive even when the gated
// subgroups thin out.
final groups = filterKeyboardShortcutActionGroupsForPlatform(
capabilities(
includeFullscreenShortcut: false,
includeScreenshotShortcut: false,
includeTabShortcuts: false,
includeToolbarShortcut: false,
includeCloseTabShortcut: false,
includeSwitchSidesShortcut: false,
includeRecordingShortcut: false,
includeResetCanvasShortcut: false,
includePinToolbarShortcut: false,
includeViewModeShortcut: false,
includeInputSourceShortcut: false,
includeVoiceCallShortcut: false,
),
);
final titles = groups.map((g) => g.titleKey).toList();
// "Other" has nothing but platform-gated entries → dropped entirely.
expect(titles, isNot(contains('Other')));
// Parent groups with cross-platform direct items survive.
expect(titles, contains('Display'));
expect(titles, contains('Keyboard'));
// The "View Mode" subgroup under Display is gated by includeViewModeShortcut,
// so it must be absent from Display's surviving children.
final displayChildren =
groups.firstWhere((g) => g.titleKey == 'Display').children;
final subgroupTitles = displayChildren
.whereType<KeyboardShortcutActionSubgroup>()
.map((s) => s.titleKey)
.toList();
expect(subgroupTitles, isNot(contains('View Mode')));
// No surviving group is empty either way.
expect(groups.every((g) => g.children.isNotEmpty), isTrue);
// No surviving subgroup is empty.
for (final group in groups) {
for (final child in group.children) {
if (child is KeyboardShortcutActionSubgroup) {
expect(child.entries, isNotEmpty,
reason: 'subgroup "${child.titleKey}" should not be empty');
}
}
}
});
test('logicalKeyName covers the supported-keys fixture', () {
// The fixture is the cross-language source of truth for the full set of
// shortcut-bindable key names. Rust has a mirror test against the same
// file (`supported_keys_match_fixture` in src/keyboard/shortcuts.rs).
// Drift on either side breaks one of the two tests.
final fixturePath = 'test/fixtures/supported_shortcut_keys.json';
final fixture =
(jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>)
.cast<String>()
.toSet();
// Hand-rolled (LogicalKeyboardKey, name) round-trip table. Adding a key
// requires updates in three places: the fixture, this table, and Rust's
// matching table — that's the price of the parity guarantee.
final mappings = <(LogicalKeyboardKey, String)>[
for (var c = 0; c < 26; c++)
(
LogicalKeyboardKey(0x00000000061 + c),
String.fromCharCode(0x61 + c),
),
for (var d = 0; d < 10; d++)
(LogicalKeyboardKey(0x00000000030 + d), 'digit$d'),
(LogicalKeyboardKey.f1, 'f1'),
(LogicalKeyboardKey.f2, 'f2'),
(LogicalKeyboardKey.f3, 'f3'),
(LogicalKeyboardKey.f4, 'f4'),
(LogicalKeyboardKey.f5, 'f5'),
(LogicalKeyboardKey.f6, 'f6'),
(LogicalKeyboardKey.f7, 'f7'),
(LogicalKeyboardKey.f8, 'f8'),
(LogicalKeyboardKey.f9, 'f9'),
(LogicalKeyboardKey.f10, 'f10'),
(LogicalKeyboardKey.f11, 'f11'),
(LogicalKeyboardKey.f12, 'f12'),
(LogicalKeyboardKey.delete, 'delete'),
(LogicalKeyboardKey.backspace, 'backspace'),
(LogicalKeyboardKey.tab, 'tab'),
(LogicalKeyboardKey.space, 'space'),
(LogicalKeyboardKey.enter, 'enter'),
(LogicalKeyboardKey.numpadEnter, 'enter'),
(LogicalKeyboardKey.arrowLeft, 'arrow_left'),
(LogicalKeyboardKey.arrowRight, 'arrow_right'),
(LogicalKeyboardKey.arrowUp, 'arrow_up'),
(LogicalKeyboardKey.arrowDown, 'arrow_down'),
(LogicalKeyboardKey.home, 'home'),
(LogicalKeyboardKey.end, 'end'),
(LogicalKeyboardKey.pageUp, 'page_up'),
(LogicalKeyboardKey.pageDown, 'page_down'),
(LogicalKeyboardKey.insert, 'insert'),
];
// Round-trip: every (key, name) pair must agree with logicalKeyName.
for (final (key, name) in mappings) {
expect(logicalKeyName(key), equals(name),
reason: 'logicalKeyName($key) should be "$name"');
}
// The set of names produced by the table must equal the fixture.
final namesFromTable = mappings.map((e) => e.$2).toSet();
expect(namesFromTable, equals(fixture),
reason: 'logicalKeyName vocabulary drifted from $fixturePath — update '
'shortcut_utils.dart::logicalKeyName, the fixture, and Rust '
'event_to_key_name together');
// Modifier-only / unsupported keys must return null.
expect(logicalKeyName(LogicalKeyboardKey.shift), isNull);
expect(logicalKeyName(LogicalKeyboardKey.escape), isNull);
expect(logicalKeyName(LogicalKeyboardKey.f13), isNull);
});
test('configurable shortcut list does not include known-removed action IDs',
() {
// These IDs were briefly defined without handlers (a "ghost action"
// footgun). If you intend to re-add one of these as a real action,
// wire up its handler and add a constant + group entry — do not just
// resurrect the literal string below.
//
// Note: `toggle_privacy_mode` was once on this list but is now a real
// implemented action (registered in shortcut_model.dart). The other
// legacy IDs (toggle_audio, view_mode_shrink/stretch, view_mode_1_to_1)
// were renamed: their replacements are kShortcutActionToggleMute and
// kShortcutActionViewModeOriginal/Adaptive/Custom.
const knownRemoved = [
'toggle_audio',
'view_mode_1_to_1',
'view_mode_shrink',
'view_mode_stretch',
];
final actions = idSet(kKeyboardShortcutActionGroups);
for (final id in knownRemoved) {
expect(actions, isNot(contains(id)),
reason:
'"$id" was a known ghost action — wire a real handler before re-adding it');
}
});
}

View File

@@ -1797,9 +1797,6 @@ impl<T: InvokeUiSession> Remote<T> {
Ok(Permission::BlockInput) => {
self.handler.set_permission("block_input", p.enabled);
}
Ok(Permission::PrivacyMode) => {
self.handler.set_permission("privacy_mode", p.enabled);
}
_ => {}
}
}

View File

@@ -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,
@@ -972,27 +974,6 @@ pub fn main_show_option(_key: String) -> SyncReturn<bool> {
}
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(
@@ -1040,29 +1021,7 @@ pub fn main_get_options_sync() -> SyncReturn<String> {
}
pub fn main_set_options(json: String) {
let mut map: HashMap<String, String> = 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
);
}
}
}
}
let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or(HashMap::new());
if !map.is_empty() {
set_options(map)
}
@@ -1771,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) {
@@ -2290,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<String> {
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<bool> {
SyncReturn(is_installed_lower_version())
}

View File

@@ -237,7 +237,6 @@ pub enum Data {
restart: bool,
recording: bool,
block_input: bool,
privacy_mode: bool,
from_switch: bool,
},
ChatMessage {

View File

@@ -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,32 @@ pub mod client {
}
pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option<i32>) {
// 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.
//
// NOTE: Shortcut matching intentionally happens BEFORE any key swapping
// (swap_modifier_key) so that shortcuts bind to the physical keys pressed,
// not the swapped keys. This makes shortcut setup intuitive: users bind
// shortcuts to the actual keys they press, regardless of swap settings.
// Key swapping only affects what gets sent to the remote.
//
// Gated on `feature = "flutter"` because the dispatch target
// (`flutter::push_session_event`) is Flutter-only. Sciter builds never
// call `reload_from_config`, so the cache stays disabled and the
// matcher would no-op anyway — but we still skip the call entirely so
// a hand-edited config can't silently swallow keys on a UI that has
// no way to surface the action.
//
// `None` for session_id makes the helper resolve through
// `flutter::get_cur_session_id()` — the rdev grab loop is process-wide
// and has no per-event session context to thread.
#[cfg(feature = "flutter")]
if crate::keyboard::shortcuts::try_dispatch(None, event) {
return;
}
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
if is_long_press(&event) {
return;
@@ -334,7 +363,20 @@ pub mod client {
event: &Event,
lock_modes: Option<i32>,
session: &Session<T>,
session_id: SessionID,
) {
// Shortcut intercept — see the long comment in `process_event` above
// for the KeyPress-only / feature-gate rationale. The only difference
// here is that the Flutter FFI path threads an explicit SessionID
// through, so dispatch targets the exact tab the keystroke originated
// from — no dependency on the global focus tracker.
#[cfg(feature = "flutter")]
if crate::keyboard::shortcuts::try_dispatch(Some(&session_id), event) {
return;
}
#[cfg(not(feature = "flutter"))]
let _ = session_id;
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
if is_long_press(&event) {
return;

723
src/keyboard/shortcuts.rs Normal file
View File

@@ -0,0 +1,723 @@
//! Keyboard shortcuts for triggering session actions locally.
use std::sync::{Arc, RwLock};
use hbb_common::log;
use serde::{Deserialize, Serialize};
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
lazy_static::lazy_static! {
static ref CACHE: RwLock<Arc<Bindings>> = 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 SWITCH_DISPLAY_ALL: &str = "switch_display_all";
pub const SCREENSHOT: &str = "screenshot";
pub const INSERT_LOCK: &str = "insert_lock";
pub const REFRESH: &str = "refresh";
pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input";
pub const TOGGLE_RECORDING: &str = "toggle_recording";
pub const SWITCH_SIDES: &str = "switch_sides";
pub const CLOSE_TAB: &str = "close_tab";
pub const TOGGLE_TOOLBAR: &str = "toggle_toolbar";
pub const RESTART_REMOTE: &str = "restart_remote";
pub const RESET_CANVAS: &str = "reset_canvas";
pub const TOGGLE_MUTE: &str = "toggle_mute";
pub const PIN_TOOLBAR: &str = "pin_toolbar";
pub const VIEW_MODE_ORIGINAL: &str = "view_mode_original";
pub const VIEW_MODE_ADAPTIVE: &str = "view_mode_adaptive";
pub const TOGGLE_CHAT: &str = "toggle_chat";
pub const TOGGLE_QUALITY_MONITOR: &str = "toggle_quality_monitor";
pub const TOGGLE_SHOW_REMOTE_CURSOR: &str = "toggle_show_remote_cursor";
pub const TOGGLE_SHOW_MY_CURSOR: &str = "toggle_show_my_cursor";
pub const TOGGLE_DISABLE_CLIPBOARD: &str = "toggle_disable_clipboard";
pub const PRIVACY_MODE_1: &str = "privacy_mode_1";
pub const PRIVACY_MODE_2: &str = "privacy_mode_2";
pub const KEYBOARD_MODE_MAP: &str = "keyboard_mode_map";
pub const KEYBOARD_MODE_TRANSLATE: &str = "keyboard_mode_translate";
pub const KEYBOARD_MODE_LEGACY: &str = "keyboard_mode_legacy";
pub const CODEC_AUTO: &str = "codec_auto";
pub const CODEC_VP8: &str = "codec_vp8";
pub const CODEC_VP9: &str = "codec_vp9";
pub const CODEC_AV1: &str = "codec_av1";
pub const CODEC_H264: &str = "codec_h264";
pub const CODEC_H265: &str = "codec_h265";
pub const PLUG_OUT_ALL_VIRTUAL_DISPLAYS: &str = "plug_out_all_virtual_displays";
pub const TOGGLE_RELATIVE_MOUSE_MODE: &str = "toggle_relative_mouse_mode";
pub const TOGGLE_FOLLOW_REMOTE_CURSOR: &str = "toggle_follow_remote_cursor";
pub const TOGGLE_FOLLOW_REMOTE_WINDOW: &str = "toggle_follow_remote_window";
pub const TOGGLE_ZOOM_CURSOR: &str = "toggle_zoom_cursor";
pub const TOGGLE_REVERSE_MOUSE_WHEEL: &str = "toggle_reverse_mouse_wheel";
pub const TOGGLE_SWAP_LEFT_RIGHT_MOUSE: &str = "toggle_swap_left_right_mouse";
pub const TOGGLE_LOCK_AFTER_SESSION_END: &str = "toggle_lock_after_session_end";
pub const TOGGLE_TRUE_COLOR: &str = "toggle_true_color";
pub const TOGGLE_SWAP_CTRL_CMD: &str = "toggle_swap_ctrl_cmd";
pub const TOGGLE_ENABLE_FILE_COPY_PASTE: &str = "toggle_enable_file_copy_paste";
pub const VIEW_MODE_CUSTOM: &str = "view_mode_custom";
pub const IMAGE_QUALITY_BEST: &str = "image_quality_best";
pub const IMAGE_QUALITY_BALANCED: &str = "image_quality_balanced";
pub const IMAGE_QUALITY_LOW: &str = "image_quality_low";
pub const SEND_CLIPBOARD_KEYSTROKES: &str = "send_clipboard_keystrokes";
pub const TOGGLE_INPUT_SOURCE: &str = "toggle_input_source";
pub const SWITCH_TAB_NEXT: &str = "switch_tab_next";
pub const SWITCH_TAB_PREV: &str = "switch_tab_prev";
pub const TOGGLE_VOICE_CALL: &str = "toggle_voice_call";
pub const TOGGLE_VIEW_ONLY: &str = "toggle_view_only";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Modifier {
Primary,
Ctrl,
Alt,
Shift,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Binding {
pub action: String,
pub mods: Vec<Modifier>,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Bindings {
#[serde(default)]
pub enabled: bool,
/// Persistent companion to `enabled`: when true, the matcher returns early
/// and every keystroke flows through to the remote (i.e. all bindings are
/// suspended). Stored alongside `enabled` and `bindings` so a single
/// reload refreshes both flags.
#[serde(default)]
pub pass_through: bool,
#[serde(default)]
pub bindings: Vec<Binding>,
}
pub fn default_bindings() -> Vec<Binding> {
let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift];
// Defaults align with AnyDesk's M/S/I/C/Delete/Arrow/Digit conventions
// where applicable; "P" for screenshot also matches AnyDesk.
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() },
Binding { action: action_id::TOGGLE_SHOW_REMOTE_CURSOR.into(), mods: prefix(), key: "m".into() },
Binding { action: action_id::TOGGLE_MUTE.into(), mods: prefix(), key: "s".into() },
Binding { action: action_id::TOGGLE_BLOCK_INPUT.into(), mods: prefix(), key: "i".into() },
Binding { action: action_id::TOGGLE_CHAT.into(), mods: prefix(), key: "c".into() },
]
}
/// Match a normalized (key, modifiers) pair against the given bindings.
/// Returns the matched action ID, or None when the matcher is off
/// (`enabled == false`), suspended (`pass_through == true`), or no binding
/// fires for this combo.
///
/// Defense-in-depth: bindings with an empty modifier list are skipped here
/// even though the recording dialog refuses to save them. A hand-edited
/// config (or a future writer-side bug) that lets an empty-mods binding
/// through would otherwise turn that key's every press into a swallowed
/// shortcut, breaking normal typing in the remote session — a much worse
/// failure than the binding simply not firing.
pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
if !b.enabled || b.pass_through {
return None;
}
for binding in &b.bindings {
if binding.mods.is_empty() {
continue;
}
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<Modifier> {
// iOS shares Apple's keyboard semantics with macOS — recording dialog
// already treats iOS as `_isMac`, so the matcher must too.
//
// AltGr conflation: `get_modifiers_state` ORs Alt and AltGr, so an
// AltGr+key press satisfies `Modifier::Alt`. Theoretical collision only;
// fix at `get_modifiers_state` if a real bug surfaces.
let mut v = Vec::new();
if cfg!(any(target_os = "macos", target_os = "ios")) {
if command { v.push(Modifier::Primary); }
if ctrl { v.push(Modifier::Ctrl); }
} else {
if ctrl { 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<String> {
use rdev::{EventType, Key};
let key = match event.event_type {
EventType::KeyPress(k) => k,
_ => return None,
};
Some(match key {
Key::Delete => "delete".into(),
Key::Backspace => "backspace".into(),
Key::Tab => "tab".into(),
Key::Space => "space".into(),
Key::Home => "home".into(),
Key::End => "end".into(),
Key::PageUp => "page_up".into(),
Key::PageDown => "page_down".into(),
Key::Insert => "insert".into(),
// Numpad Enter (`KpReturn`) shares the "enter" name with the main
// Return key — matches the Web matcher (`NumpadEnter` -> "enter") and
// matches user expectation that the two physical Enters are
// interchangeable for shortcuts.
Key::Return | Key::KpReturn => "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::Num0 => "digit0".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(),
Key::F1 => "f1".into(),
Key::F2 => "f2".into(),
Key::F3 => "f3".into(),
Key::F4 => "f4".into(),
Key::F5 => "f5".into(),
Key::F6 => "f6".into(),
Key::F7 => "f7".into(),
Key::F8 => "f8".into(),
Key::F9 => "f9".into(),
Key::F10 => "f10".into(),
Key::F11 => "f11".into(),
Key::F12 => "f12".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 {
match serde_json::from_str(&raw) {
Ok(parsed) => parsed,
Err(e) => {
log::warn!("Failed to parse keyboard shortcut config: {}", e);
Bindings::default()
}
}
};
match CACHE.write() {
Ok(mut w) => {
*w = Arc::new(parsed);
}
Err(poison) => {
log::error!("Keyboard shortcut cache write lock is poisoned");
*poison.into_inner() = Arc::new(parsed);
}
}
}
/// Snapshot of the currently cached bindings. Cheap (one atomic increment) —
/// safe to call on every keystroke.
pub fn current() -> Arc<Bindings> {
match CACHE.read() {
Ok(b) => Arc::clone(&b),
Err(poison) => {
log::error!("Keyboard shortcut cache read lock is poisoned");
Arc::clone(&poison.into_inner())
}
}
}
/// 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.
///
/// ── Two known minor warts. DO NOT add global state to "fix" either: ──
///
/// 1. Orphan KeyRelease forwarded to peer.
/// When a shortcut matches we eat the KeyPress, but the matching
/// KeyRelease (whose `event_type` returns None from `event_to_key_name`)
/// still flows through to the peer. The remote sees a release for a
/// press it never received. Every input server we forward to ignores
/// releases for unpressed keys, so user-visible impact is nil — the
/// pre-existing hard-coded screenshot-shortcut path had the same shape
/// for years without a single bug report.
///
/// 2. OS auto-repeat re-dispatches a held shortcut.
/// rdev does not expose an `is_repeat` flag, so a held combo
/// (Cmd+Alt+Shift+P) would dispatch every ~30-50ms while the keys are
/// down — toggle actions oscillate, screenshot fires many times. In
/// practice the OS initial auto-repeat delay is ~250ms and a normal
/// shortcut press is 50-100ms, so the user has to *deliberately* hold
/// the combo to hit this. The Web side gets a free fix via the
/// browser's `KeyboardEvent.repeat`; on native we accept the wart.
///
/// The "fix" for either would be a process-global `HashSet<rdev::Key>` (or
/// equivalent) with paired insert-on-press / remove-on-release logic in
/// both `process_event*` paths plus a clear-on-leave hook. The cost:
///
/// * Lock contention on the hot keystroke path.
/// * Three input sources (rdev grab, Flutter raw key, Flutter USB HID)
/// all converge to `rdev::Key`, so correctness depends on
/// `rdev::key_from_code` / `rdev::usb_hid_key_from_code` /
/// `rdev::get_win_key` agreeing on the same physical key — the project
/// already has scattered swap_modifier_key / ControlLeft↔MetaLeft
/// fixups for places where they historically *didn't* agree. Any new
/// mismatch silently leaks the set; "shortcut stopped responding"
/// after a stuck entry is a worse failure mode than "shortcut fired
/// twice."
/// * Leak risk on focus loss / disconnect, requiring a clear hook the
/// callers must remember to invoke.
/// * Two new code paths to keep in lockstep with two existing keyboard
/// pipelines.
///
/// For two warts whose user-visible impact is nil-to-marginal, that
/// trade-off goes the wrong way. Leave it. If a real user bug shows up
/// here, revisit then with concrete repro — not pre-emptively.
pub fn match_event(event: &rdev::Event) -> Option<String> {
let bindings = current();
if !bindings.enabled || bindings.pass_through {
return None;
}
// Note: `match_normalized` re-checks both flags below — this short-circuit
// is just to avoid the `event_to_key_name` + `get_modifiers_state` work
// in the common bypass case.
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)
}
/// Match `event` against the cached bindings; if it matched, push a
/// `shortcut_triggered` Flutter session event and return `true` so the caller
/// can `return` early. Returns `false` when no shortcut fired (caller should
/// continue with normal key handling).
///
/// `session_id`:
/// * `Some(&id)` — Flutter FFI path: dispatch to the exact session whose key
/// event we're processing. No dependence on the global focus tracker.
/// * `None` — rdev grab loop: the loop is process-wide and has no way to know
/// which Flutter session id the keystroke was meant for, so route to the
/// globally-current session via `flutter::get_cur_session_id()`.
#[cfg(feature = "flutter")]
pub fn try_dispatch(session_id: Option<&hbb_common::SessionID>, event: &rdev::Event) -> bool {
let Some(action_id) = match_event(event) else {
return false;
};
let resolved;
let sid = match session_id {
Some(id) => id,
None => {
resolved = crate::flutter::get_cur_session_id();
&resolved
}
};
crate::flutter::push_session_event(sid, "shortcut_triggered", vec![("action", &action_id)]);
true
}
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,
// macOS users can bind shortcuts that use Control independently
// of Command. On Win/Linux this variant should never appear in a
// saved binding (`normalize_modifiers` collapses Ctrl into
// Primary), but we still give it a distinct bit so a hand-edited
// config can't accidentally collide with another modifier.
Modifier::Ctrl => 8,
};
}
bits
}
fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool {
mods_bits(a) == mods_bits(b)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_press(k: rdev::Key) -> rdev::Event {
rdev::Event {
time: std::time::SystemTime::now(),
unicode: None,
platform_code: 0,
position_code: 0,
event_type: rdev::EventType::KeyPress(k),
usb_hid: 0,
#[cfg(any(target_os = "windows", target_os = "macos"))]
extra_data: 0,
}
}
#[test]
fn event_to_key_name_handles_f_keys() {
use rdev::Key;
assert_eq!(event_to_key_name(&make_press(Key::F1)), Some("f1".into()));
assert_eq!(event_to_key_name(&make_press(Key::F5)), Some("f5".into()));
assert_eq!(event_to_key_name(&make_press(Key::F12)), Some("f12".into()));
}
/// Cross-language parity for default bindings. The fixture file is the
/// shared source of truth — Dart has a mirror test against the same file
/// (`kDefaultShortcutBindings matches fixture` in
/// `flutter/test/keyboard_shortcuts_test.dart`). Any drift on either
/// side breaks one of the two tests.
#[test]
fn default_bindings_match_fixture_json() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../flutter/test/fixtures/default_keyboard_shortcuts.json"
))
.expect("fixture is valid JSON");
let actual: serde_json::Value =
serde_json::to_value(default_bindings()).expect("serialize defaults");
assert_eq!(
fixture, actual,
"default_bindings() drifted from \
flutter/test/fixtures/default_keyboard_shortcuts.json — update \
shortcuts.rs, the fixture, and Dart kDefaultShortcutBindings together"
);
}
#[test]
fn event_to_key_name_treats_numpad_enter_as_enter() {
use rdev::{Event, EventType, Key};
let make = |k: Key| Event {
time: std::time::SystemTime::now(),
unicode: None,
platform_code: 0,
position_code: 0,
event_type: EventType::KeyPress(k),
usb_hid: 0,
#[cfg(any(target_os = "windows", target_os = "macos"))]
extra_data: 0,
};
assert_eq!(event_to_key_name(&make(Key::Return)), Some("enter".into()));
assert_eq!(event_to_key_name(&make(Key::KpReturn)), Some("enter".into()));
}
#[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(&action_id::TOGGLE_SHOW_REMOTE_CURSOR));
assert!(actions.contains(&action_id::TOGGLE_MUTE));
assert!(actions.contains(&action_id::TOGGLE_BLOCK_INPUT));
assert!(actions.contains(&action_id::TOGGLE_CHAT));
// 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_pass_through() {
let bindings = Bindings {
enabled: true,
pass_through: true,
bindings: default_bindings(),
};
let result = match_normalized(
"p",
&[Modifier::Primary, Modifier::Alt, Modifier::Shift],
&bindings,
);
assert_eq!(result, None);
}
#[test]
fn match_returns_none_when_disabled() {
let bindings = Bindings { enabled: false, pass_through: 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, pass_through: false, 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, pass_through: false, 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, pass_through: false, 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,
pass_through: false,
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!(any(target_os = "macos", target_os = "ios")) {
// On Apple platforms Ctrl is NOT primary
assert!(!mods.contains(&Modifier::Primary));
assert!(mods.contains(&Modifier::Ctrl));
} else {
assert!(mods.contains(&Modifier::Primary));
}
assert!(mods.contains(&Modifier::Alt));
assert!(mods.contains(&Modifier::Shift));
}
#[test]
fn modifier_normalization_command_is_primary_on_apple() {
let mods = normalize_modifiers(true, false, true, /*command=*/true);
if cfg!(any(target_os = "macos", target_os = "ios")) {
assert!(mods.contains(&Modifier::Primary));
} else {
// On Win/Linux Command/Meta is NOT primary
assert!(!mods.contains(&Modifier::Primary));
}
}
#[test]
fn match_refuses_zero_modifier_bindings() {
// Defense-in-depth: a hand-edited config with empty `mods` must NOT
// turn every plain "P" press into a screenshot shortcut, which would
// swallow all typing in the remote session. The recording dialog
// already refuses to save such bindings, but the matcher must hold
// the line independently.
let bindings = Bindings {
enabled: true,
pass_through: false,
bindings: vec![Binding {
action: "screenshot".into(),
mods: vec![],
key: "p".into(),
}],
};
assert_eq!(match_normalized("p", &[], &bindings), None);
// Even with extra modifiers held by the user, a zero-mod binding
// still doesn't match (no shape of held modifiers can equal the
// empty saved set after the empty-check skips the entry).
assert_eq!(
match_normalized("p", &[Modifier::Primary], &bindings),
None,
);
}
/// Cross-language parity for the full set of shortcut-bindable key
/// names (not just the defaults). The fixture lists every name the
/// matcher accepts; this test verifies the (rdev::Key → name) round-trip
/// covers exactly that set. Dart has a mirror test against the same
/// fixture (`logicalKeyName covers the supported-keys fixture` in
/// `flutter/test/keyboard_shortcuts_test.dart`).
///
/// Adding a key requires updates in three places: the fixture, this
/// table, and the Dart `logicalKeyName` — that's the price of the
/// parity guarantee. Drift on any side breaks one of the two tests.
#[test]
fn supported_keys_match_fixture() {
use rdev::Key;
use std::collections::BTreeSet;
let table: &[(&str, Key)] = &[
("a", Key::KeyA), ("b", Key::KeyB), ("c", Key::KeyC),
("d", Key::KeyD), ("e", Key::KeyE), ("f", Key::KeyF),
("g", Key::KeyG), ("h", Key::KeyH), ("i", Key::KeyI),
("j", Key::KeyJ), ("k", Key::KeyK), ("l", Key::KeyL),
("m", Key::KeyM), ("n", Key::KeyN), ("o", Key::KeyO),
("p", Key::KeyP), ("q", Key::KeyQ), ("r", Key::KeyR),
("s", Key::KeyS), ("t", Key::KeyT), ("u", Key::KeyU),
("v", Key::KeyV), ("w", Key::KeyW), ("x", Key::KeyX),
("y", Key::KeyY), ("z", Key::KeyZ),
("digit0", Key::Num0), ("digit1", Key::Num1),
("digit2", Key::Num2), ("digit3", Key::Num3),
("digit4", Key::Num4), ("digit5", Key::Num5),
("digit6", Key::Num6), ("digit7", Key::Num7),
("digit8", Key::Num8), ("digit9", Key::Num9),
("f1", Key::F1), ("f2", Key::F2), ("f3", Key::F3),
("f4", Key::F4), ("f5", Key::F5), ("f6", Key::F6),
("f7", Key::F7), ("f8", Key::F8), ("f9", Key::F9),
("f10", Key::F10), ("f11", Key::F11), ("f12", Key::F12),
("delete", Key::Delete),
("backspace", Key::Backspace),
("tab", Key::Tab),
("space", Key::Space),
("enter", Key::Return),
("enter", Key::KpReturn),
("arrow_left", Key::LeftArrow),
("arrow_right", Key::RightArrow),
("arrow_up", Key::UpArrow),
("arrow_down", Key::DownArrow),
("home", Key::Home),
("end", Key::End),
("page_up", Key::PageUp),
("page_down", Key::PageDown),
("insert", Key::Insert),
];
// Round-trip: every entry in the table must map through
// event_to_key_name to its declared name.
for (name, key) in table {
assert_eq!(
event_to_key_name(&make_press(*key)).as_deref(),
Some(*name),
"rdev::Key::{:?} should map to {:?}",
key, name,
);
}
// The set of names produced by the table must equal the fixture.
let actual: BTreeSet<&str> = table.iter().map(|(n, _)| *n).collect();
let fixture_raw: Vec<String> = serde_json::from_str(include_str!(
"../../flutter/test/fixtures/supported_shortcut_keys.json"
))
.expect("fixture is valid JSON");
let expected: BTreeSet<&str> =
fixture_raw.iter().map(String::as_str).collect();
assert_eq!(
actual, expected,
"event_to_key_name vocabulary drifted from \
flutter/test/fixtures/supported_shortcut_keys.json — update \
shortcuts.rs, the fixture, and Dart logicalKeyName together"
);
}
#[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);
}
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "اسم العرض"),
("password-hidden-tip", "كلمة المرور مخفية"),
("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "Імя для адлюстравання"),
("password-hidden-tip", "Зададзены пастаянны пароль (скрыты)."),
("preset-password-in-use-tip", "Пададзены пароль цяпер выкарыстоўваецца"),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "显示名称"),
("password-hidden-tip", "永久密码已设置(已隐藏)"),
("preset-password-in-use-tip", "当前使用预设密码"),
("Enable privacy mode", "允许隐私模式"),
("Keyboard Shortcuts", "键盘快捷键"),
("Configure shortcuts...", "配置快捷键..."),
("Enable keyboard shortcuts in remote session", "在远程会话中启用键盘快捷键"),
("shortcut-page-description", "为下列每项会话操作绑定一个组合键。每个绑定至少需要包含一个修饰符。"),
("shortcut-passthrough-tip", "开启后,所有已绑定的组合键都会原样转发到远端。适合在某个组合键与远端需要使用的快捷键冲突时打开。"),
("Pass-through to remote", "穿透到远端"),
("Reset to defaults", "恢复默认设置"),
("shortcut-reset-confirm-tip", "这将以默认快捷键替换所有当前绑定。是否继续?"),
("Monitor", "显示器"),
("Keyboard", "键盘"),
("Toggle fullscreen", "切换全屏"),
("Switch to next display", "切换到下一个显示器"),
("Switch to previous display", "切换到上一个显示器"),
("All monitors", "所有显示器"),
("Monitor #{}", "{} 号显示器"),
("Switch to next tab", "切换到下一个标签"),
("Switch to previous tab", "切换到上一个标签"),
("Toggle session recording", "切换会话录制"),
("Close tab", "关闭标签页"),
("Toggle toolbar", "切换工具栏可见性"),
("Toggle input source", "切换输入源"),
("Edit", "编辑"),
("Save", "保存"),
("Set Shortcut", "设置快捷键"),
("shortcut-recording-instruction", "请按下您想使用的组合键。"),
("shortcut-recording-press-keys-tip", "请按下组合键..."),
("shortcut-must-include-modifiers", "必须至少包含一个修饰符:{}"),
("shortcut-already-bound-to", "已绑定到"),
("Replace", "替换"),
("Valid", "有效"),
("shortcut-mobile-physical-keyboard-tip", "录制需要使用物理键盘,不支持软键盘。"),
("shortcut-key-not-supported", "“{}” 不能用作快捷键。"),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ 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", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "Εμφανιζόμενο όνομα"),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -274,5 +274,14 @@ 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."),
("shortcut-page-description", "Bind a key combination to each session action below. Each binding must include at least one modifier."),
("shortcut-passthrough-tip", "When on, every bound combination is forwarded to the remote. Useful when a binding collides with something you need on the remote."),
("shortcut-reset-confirm-tip", "This will replace all current bindings with the default set. Continue?"),
("shortcut-recording-instruction", "Press the key combination you want to use."),
("shortcut-recording-press-keys-tip", "Press a key combination..."),
("shortcut-must-include-modifiers", "Must include at least one modifier: {}"),
("shortcut-already-bound-to", "Already bound to"),
("shortcut-mobile-physical-keyboard-tip", "Recording requires a physical keyboard. Soft keyboards are not supported."),
("shortcut-key-not-supported", "\"{}\" can't be used as a shortcut."),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "Nom daffichage"),
("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", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -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", "આ માટે {} ક્લાયન્ટ ઇન્સ્ટોલ હોવું જોઈએ."),
@@ -742,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "ડિસ્પ્લે નામ"),
("password-hidden-tip", "સુરક્ષા માટે પાસવર્ડ છુપાવેલ છે."),
("preset-password-in-use-tip", "પ્રીસેટ પાસવર્ડ વપરાશમાં છે."),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -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,39 @@ 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", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ 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", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "Visualizza nome"),
("password-hidden-tip", "È impostata una password permanente (nascosta)."),
("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."),
("Enable privacy mode", "Abilita modalità privacy"),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "表示名"),
("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"),
("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "표시 이름"),
("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."),
("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -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,39 @@ 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", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ 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", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ 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", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -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", ""),
("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"),
@@ -743,6 +743,39 @@ 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", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "Отображаемое имя"),
("password-hidden-tip", "Установлен постоянный пароль (скрытый)."),
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
("Enable privacy mode", "Использовать режим конфиденциальности"),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -741,8 +741,41 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranıık tutun"),
("Continue with {}", "{} ile devam et"),
("Display Name", "Görünen Ad"),
("password-hidden-tip", "Parola gizli"),
("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"),
("Enable privacy mode", "Gizlilik modunu etkinleştir"),
("password-hidden-tip", "Şifre gizli"),
("preset-password-in-use-tip", "Önceden ayarlanmış şifre kullanılıyor"),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "顯示名稱"),
("password-hidden-tip", "固定密碼已設定(已隱藏)"),
("preset-password-in-use-tip", "目前正在使用預設密碼"),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -743,6 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", ""),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("Enable privacy mode", ""),
("Keyboard Shortcuts", ""),
("Configure shortcuts...", ""),
("Enable keyboard shortcuts in remote session", ""),
("shortcut-page-description", ""),
("shortcut-passthrough-tip", ""),
("Pass-through to remote", ""),
("Reset to defaults", ""),
("shortcut-reset-confirm-tip", ""),
("Monitor", ""),
("Keyboard", ""),
("Toggle fullscreen", ""),
("Switch to next display", ""),
("Switch to previous display", ""),
("All monitors", ""),
("Monitor #{}", ""),
("Switch to next tab", ""),
("Switch to previous tab", ""),
("Toggle session recording", ""),
("Close tab", ""),
("Toggle toolbar", ""),
("Toggle input source", ""),
("Edit", ""),
("Save", ""),
("Set Shortcut", ""),
("shortcut-recording-instruction", ""),
("shortcut-recording-press-keys-tip", ""),
("shortcut-must-include-modifiers", ""),
("shortcut-already-bound-to", ""),
("Replace", ""),
("Valid", ""),
("shortcut-mobile-physical-keyboard-tip", ""),
("shortcut-key-not-supported", ""),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -241,7 +241,6 @@ pub struct Connection {
restart: bool,
recording: bool,
block_input: bool,
privacy_mode: bool,
control_permissions: Option<ControlPermissions>,
last_test_delay: Option<Instant>,
network_delay: u32,
@@ -432,7 +431,6 @@ 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,
@@ -529,9 +527,6 @@ 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();
@@ -679,46 +674,6 @@ 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) => {
@@ -1023,7 +978,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, String::new());
let _ = Self::turn_off_privacy_to_msg(id);
}
}
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
@@ -1945,7 +1900,6 @@ impl Connection {
restart: self.restart,
recording: self.recording,
block_input: self.block_input,
privacy_mode: self.privacy_mode,
from_switch: self.from_switch,
});
}
@@ -2221,7 +2175,6 @@ 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 {
@@ -4192,15 +4145,6 @@ 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,
@@ -4242,7 +4186,7 @@ impl Connection {
"Check privacy mode failed: {}, turn off privacy mode.",
&err_msg
);
let _ = Self::turn_off_privacy_to_msg(self.inner.id, String::new());
let _ = Self::turn_off_privacy_to_msg(self.inner.id);
crate::common::make_privacy_mode_msg_with_details(
back_notification::PrivacyModeState::PrvOnFailed,
err_msg,
@@ -4261,7 +4205,6 @@ 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(
@@ -4289,23 +4232,14 @@ impl Connection {
impl_key,
)
} else {
Self::turn_off_privacy_to_msg(self.inner.id, impl_key)
Self::turn_off_privacy_to_msg(self.inner.id)
};
self.send(msg_out).await;
}
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<hbb_common::ResultType<()>>,
impl_key: String,
) -> Message {
match turn_off_res {
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) {
Some(Ok(_)) => crate::common::make_privacy_mode_msg(
back_notification::PrivacyModeState::PrvOffSucceeded,
impl_key,

View File

@@ -372,11 +372,6 @@ 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()
}
@@ -757,7 +752,6 @@ 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);

View File

@@ -93,13 +93,6 @@ 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=');
}
@@ -128,10 +121,6 @@ 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;

View File

@@ -36,8 +36,7 @@ impl InvokeUiCM for SciterHandler {
client.file,
client.restart,
client.recording,
client.block_input,
client.privacy_mode
client.block_input
),
);
}
@@ -158,18 +157,9 @@ 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 {
@@ -191,8 +181,6 @@ 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();
}
}

View File

@@ -4,9 +4,6 @@ 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 = <svg t="1667992597853" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1850" width="16" height="16" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M892.761 160.724v426.504c0 25.588-6.419 51.036-19.177 76.339-12.798 25.336-29.547 49.86-50.254 73.627-20.707 23.79-44.372 46.296-70.97 67.516-26.589 21.244-53.543 40.177-80.921 56.768-27.363 16.623-53.968 30.461-79.801 41.438-25.809 11.008-48.433 18.547-67.871 22.64l-9.203 1.53-8.43-1.53c-19.958-4.093-43.094-11.632-69.432-22.64-26.337-10.969-53.708-24.816-82.080-41.438-28.388-16.591-56.256-35.524-83.618-56.768-27.378-21.219-51.776-43.725-73.265-67.516-21.488-23.759-38.868-48.291-52.155-73.627-13.319-25.305-19.974-50.759-19.974-76.339v-426.504l31.455-4.629 352.892-65.97 359.784 65.97 23.017 4.629zM510.028 151.884l-4.211-0.844-302.89 51.476v269.101h307.102v-319.734zM815.434 471.634h-305.406v383.031c19.682-4.51 41.052-11.411 64.141-20.692 23.033-9.249 45.815-20.234 68.304-32.867 22.513-12.672 44.159-26.739 64.969-42.203 20.818-15.472 39.23-32.047 55.277-49.797 16.024-17.703 28.822-36.131 38.386-55.222 9.549-19.131 14.328-38.553 14.328-58.235v-124.015z" p-id="1851" fill="#ffffff"></path></svg>;
var hide_cm = undefined;
@@ -38,7 +35,6 @@ 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';
@@ -62,16 +58,15 @@ class Body: Reactor.Component
</div>
<div />
{c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" : <div>{translate('Permissions')}</div>}
{c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" : <div> <div class={permissions_locked ? "permissions locked" : "permissions"} style={permissions_locked ? "opacity:0.6;" : ""}>
{c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" : <div> <div .permissions>
<div class={!c.keyboard ? "disabled" : ""} title={translate('Enable keyboard/mouse')}><icon .keyboard /></div>
<div class={!c.clipboard ? "disabled" : ""} title={translate('Enable clipboard')}><icon .clipboard /></div>
<div class={!c.audio ? "disabled" : ""} title={translate('Enable audio')}><icon .audio /></div>
<div class={!c.file ? "disabled" : ""} title={translate('Enable file copy and paste')}><icon .file /></div>
<div class={!c.restart ? "disabled" : ""} title={translate('Enable remote restart')}><icon .restart /></div>
</div> <div class={permissions_locked ? "permissions locked" : "permissions"} style={permissions_locked ? "margin-top:8px;opacity:0.6;" : "margin-top:8px;"} >
</div> <div .permissions style="margin-top:8px;" >
<div class={!c.recording ? "disabled" : ""} title={translate('Enable recording session')}><icon .recording /></div>
<div class={!c.block_input ? "disabled" : ""} title={translate('Enable blocking user input')} style={is_win ? "" : "display:none;"}><icon .block_input /></div>
<div class={!c.privacy_mode ? "disabled" : ""} title={translate('Enable privacy mode')} style={is_privacy_mode_supported ? "" : "display:none;"}><icon .privacy_mode /></div>
</div></div>
}
{c.is_file_transfer ? <div>{translate('Transfer file')}</div> : ""}
@@ -108,7 +103,6 @@ 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;
@@ -118,7 +112,6 @@ 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;
@@ -128,7 +121,6 @@ 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;
@@ -138,7 +130,6 @@ 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;
@@ -148,7 +139,6 @@ 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;
@@ -158,7 +148,6 @@ 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;
@@ -168,7 +157,6 @@ 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;
@@ -177,16 +165,6 @@ 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() {
@@ -390,7 +368,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, privacy_mode) {
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) {
stdout.println("new connection #" + id + ": " + peer_id);
var conn;
connections.map(function(c) {
@@ -398,7 +376,6 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin
});
if (conn) {
conn.authorized = authorized;
conn.privacy_mode = privacy_mode;
update();
return;
}
@@ -414,7 +391,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, privacy_mode:privacy_mode,
block_input:block_input,
disconnected: false
};
if (idx < 0) {
@@ -503,21 +480,15 @@ 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();
});

View File

@@ -218,7 +218,7 @@ class Header: Reactor.Component {
{is_file_copy_paste_supported && file_enabled ? <li #enable-file-copy-paste .toggle-option><span>{svg_checkmark}</span>{translate('Enable file copy and paste')}</li> : ""}
{keyboard_enabled && clipboard_enabled ? <li #disable-clipboard .toggle-option><span>{svg_checkmark}</span>{translate('Disable clipboard')}</li> : ""}
{keyboard_enabled ? <li #lock-after-session-end .toggle-option><span>{svg_checkmark}</span>{translate('Lock after session end')}</li> : ""}
{(pi.platform == "Windows" || pi.platform == "Mac OS") && (handler.get_toggle_option("privacy-mode") || (keyboard_enabled && privacy_mode_enabled)) ? <li #privacy-mode><span>{svg_checkmark}</span>{translate('Privacy mode')}</li> : ""}
{keyboard_enabled && pi.platform == "Windows" ? <li #privacy-mode><span>{svg_checkmark}</span>{translate('Privacy mode')}</li> : ""}
{keyboard_enabled && ((is_osx && pi.platform != "Mac OS") || (!is_osx && pi.platform == "Mac OS")) ? <li #allow_swap_key .toggle-option><span>{svg_checkmark}</span>{translate('Swap control-command key')}</li> : ""}
{handler.version_cmp(pi.version, '1.2.4') >= 0 ? <li #i444><span>{svg_checkmark}</span>{translate('True color (4:4:4)')}</li> : ""}
</menu>

View File

@@ -521,7 +521,6 @@ class MyIdMenu: Reactor.Component {
{!disable_settings && <li #enable-remote-restart><span>{svg_checkmark}</span>{translate('Enable remote restart')}</li>}
{!disable_settings && <li #enable-tunnel><span>{svg_checkmark}</span>{translate('Enable TCP tunneling')}</li>}
{!disable_settings && is_win ? <li #enable-block-input><span>{svg_checkmark}</span>{translate('Enable blocking user input')}</li> : ""}
{!disable_settings && (handler.get_supported_privacy_mode_impls() != '[]') && <li #enable-privacy-mode><span>{svg_checkmark}</span>{translate('Enable privacy mode')}</li>}
{!disable_settings && <li #enable-lan-discovery><span>{svg_checkmark}</span>{translate('Enable LAN discovery')}</li>}
<AudioInputs />
<Enhancements />

View File

@@ -17,7 +17,6 @@ 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 = "";
@@ -589,7 +588,6 @@ 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();
});

View File

@@ -12,10 +12,7 @@ use hbb_common::fs::serialize_transfer_job;
use hbb_common::tokio::sync::mpsc::unbounded_channel;
use hbb_common::{
allow_err, bail,
config::{
keys::{OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, OPTION_FILE_TRANSFER_MAX_FILES},
option2bool, Config,
},
config::{keys::OPTION_FILE_TRANSFER_MAX_FILES, Config},
fs::{self, get_string, is_write_need_confirmation, new_send_confirm, DigestCheckResult},
log,
message_proto::*,
@@ -28,7 +25,10 @@ use hbb_common::{
ResultType,
};
#[cfg(target_os = "windows")]
use hbb_common::{config::keys::*, tokio::sync::Mutex as TokioMutex};
use hbb_common::{
config::{keys::*, option2bool},
tokio::sync::Mutex as TokioMutex,
};
use serde_derive::Serialize;
#[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))]
use std::iter::FromIterator;
@@ -143,7 +143,6 @@ 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,
@@ -231,7 +230,6 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
restart: bool,
recording: bool,
block_input: bool,
privacy_mode: bool,
from_switch: bool,
#[cfg(not(any(target_os = "ios")))] tx: mpsc::UnboundedSender<Data>,
) {
@@ -253,7 +251,6 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
restart,
recording,
block_input,
privacy_mode,
from_switch,
#[cfg(not(any(target_os = "ios")))]
tx,
@@ -395,23 +392,6 @@ 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 }));
};
@@ -420,19 +400,6 @@ 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(),
@@ -455,13 +422,6 @@ 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")))]
@@ -543,9 +503,9 @@ impl<T: InvokeUiCM> IpcTaskRunner<T> {
}
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, privacy_mode, 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, 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, privacy_mode, 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, from_switch, self.tx.clone());
self.conn_id = id;
#[cfg(target_os = "windows")]
{
@@ -573,26 +533,6 @@ impl<T: InvokeUiCM> IpcTaskRunner<T> {
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 {
@@ -895,7 +835,6 @@ pub async fn start_listen<T: InvokeUiCM>(
restart,
recording,
block_input,
privacy_mode,
from_switch,
..
}) => {
@@ -917,7 +856,6 @@ pub async fn start_listen<T: InvokeUiCM>(
restart,
recording,
block_input,
privacy_mode,
from_switch,
tx.clone(),
);

View File

@@ -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<T: InvokeUiSession> Session<T> {
#[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<T: InvokeUiSession> Session<T> {
#[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<T: InvokeUiSession> Session<T> {
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<T: InvokeUiSession> Session<T> {
#[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<T: InvokeUiSession> Session<T> {
#[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<T: InvokeUiSession> Session<T> {
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<T: InvokeUiSession> Session<T> {
fn _handle_key_non_flutter_simulation(
&self,
session_id: SessionID,
keyboard_mode: &str,
character: &str,
usb_hid: i32,
@@ -1092,7 +1105,13 @@ impl<T: InvokeUiSession> Session<T> {
#[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