From 074006386337c1c695f432be99e751a90d4e737f Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:44 +0800 Subject: [PATCH] Keyboard shortcuts (#14933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848) * 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. * langs * fix(keyboard): shortcuts, harden config and callback lifecycle Signed-off-by: fufesou * feat(keyboard): shortcuts, color of "Reset to defaults" Signed-off-by: fufesou * feat(keyboard): shortcuts, release keys before shortcut callback Signed-off-by: fufesou * feat(keyboard): shortcuts, debug web Signed-off-by: fufesou * fix(keyboard): iPad, icon Signed-off-by: fufesou * langs: add shortcut keys to az, gl, ur Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(keyboard): shortcuts, keep local modifiers and release keys on the right session When a chord fired, the shortcut path reused release_remote_keys(), which replays synthetic releases through process_event(). That cleared the local MODIFIERS_STATE while the user still held the modifiers, so the next key of a held chord no longer matched and leaked to the remote, and it sent the releases to the globally current session instead of the one that typed the chord. The shortcut path now releases the remote keys itself: it sends through the caller's session, and restores the modifiers it released on the remote as still held locally. The press that fired is remembered until its release, so its auto repeats and its release are consumed instead of reaching the remote. Also state the contract for unavailable actions: a bound chord always belongs to RustDesk and is a no-op when the session has no handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(keyboard): shortcuts, record the physical key The recording dialog and the Web Flutter matcher named keys by LogicalKeyboardKey, while the native matcher sees the physical key through its USB HID usage and the Web JS matcher through KeyboardEvent.code. On AZERTY, QWERTZ, Dvorak and similar layouts a recorded binding then did not fire, or fired from another key. Name keys by their physical position everywhere: physicalKeyName maps the USB HID usage to the stored name. A new fixture pins every name to its USB HID usage, checked on the Dart side and against rdev::usb_hid_key_from_code on the Rust side, and a test covers AZERTY and QWERTZ events. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(keyboard): shortcuts, keep an empty binding list on re-enable setEnabled seeded the defaults whenever the binding list was empty, so a user who cleared every binding got all defaults back after disable and enable. Seed only when the config has never held a bindings list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(keyboard): shortcuts, track every fired key until its release FIRED_KEY held a single key, so overlapping shortcut keys broke it: with the chord held, P then C overwrote P, P's release reached the remote, and a P repeat could fire the screenshot again. A repeat after the modifiers were released was also treated as a new press and leaked to the remote. Keep a set of fired keys instead. Once a press fires, every event of that key belongs to RustDesk until its release: repeats are consumed without dispatching, whatever the modifiers do, and the release removes the key. Entries whose release never arrives are dropped when a session view is entered or a session is closed, not by guessing from the modifiers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(keyboard): shortcuts, keep fired keys per session FIRED_KEYS was process-global while dispatch is per session. Closing or entering any session cleared the fired keys of all of them, so a key still held in another session leaked its release to that remote, and a key fired in one session made the same key in another session look like a repeat. Key the fired set by session: try_dispatch resolves the session first and only looks at that session's keys, and session_close clears only the closing session. Entering a session view no longer clears anything: it is a pointer enter, and a fired key may still be physically held across it. A key whose release is missed otherwise heals itself: its next press is consumed as a repeat and that release removes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(shortcuts): preserve input state and refresh action availability * fix(keyboard): shortcuts, keep a fired key owned across session close Close tab is itself a shortcut, so the session that consumed the key press is gone before the key is released. Both matchers dropped the key with the session: the Flutter dispatcher removed its own entries in clear(), and the Rust set was keyed by session and cleared in session_close. The release, and the repeats while the key stayed held, then reached the next session's remote as ordinary input. Ownership of a fired key is physical, so it is now one set shared by every session on each side, and closing a session no longer touches it: clear() only drops the pending action and modifier replay, and the Rust session state only tracks released modifiers. A key whose release is missed heals itself on its next press. Entering view-only with the Flutter input source still clears the Rust set, since that source stops routing key events to it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(keyboard): shortcuts, drop the Toggle Input Source action The action switches the key capture backend between key down and key up. Fired-key ownership lives in whichever matcher saw the press, the Flutter dispatcher or the Rust set, and there is no handoff between them: after the switch the repeats and the release of the held key reach the other backend, which forwards them to the remote and, while the chord is held, matches the action again and toggles the source back. Remove the shortcut instead of building a handoff for one action. The toolbar radio menu is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * perf(keyboard): shortcuts, parse the config once per stored value The Flutter matcher read the local config three times and parsed its JSON three times on every key press in legacy mode, on Web and in view-only. Keep one parsed snapshot keyed by the raw stored string, so a key press costs one config read and no parsing while the config is unchanged. The existing readers are thin views of the same snapshot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix(keyboard): shortcuts, run focus-changing actions on key release Close tab and tab switching move keyboard focus to another session before the key that fired them is released. On Linux the Flutter input source routes map-mode sessions through the Rust matcher and legacy-mode sessions through the Dart matcher, and neither knows the other's fired keys, so after such an action the repeats and the release of the held key reached the next session's remote, and the key stayed recorded as fired in the matcher that saw the press. Routing Linux legacy mode through the Rust FFI path instead is not a drop-in change: the raw key handler builds its event with no unicode, so Rust's legacy mode would fall back to US key names and lose the layout-aware characters the Dart path sends today, and it would take modifiers from the X11 key state instead of Flutter's. Run these three actions when the fired key is released. The matcher that consumed the press then also sees the repeats and the release, the remote modifiers are still released on the press, and a session that closes drops the action it owed. Both matchers keep the same list of actions. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * test(keyboard): shortcuts, pin the key-up action list on both sides kShortcutActionsRunOnKeyUp and runs_on_release are two hand-kept lists of the actions that run on key release. Add a shared fixture and check each side against it, the way the key vocabulary and the default bindings are already checked, so adding a focus-changing action on one side breaks a test until the other side follows. The Dart test also checks that every listed id is a configurable action. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D * fix: prevent lost input after remote session shortcuts Avoid synthetic Alt presses that activate the Windows system menu and swallow the next character. Trigger restart, switch-sides, and chat actions on key release so focus changes cannot leave subsequent shortcut presses ignored. Signed-off-by: fufesou * fix(mobile): correct Chat keyboard and input helper behavior - Keep soft keyboard and helper hidden when Chat opens via shortcut - Restore soft input on touch and remote focus after Chat closes - Handle modifier releases while Chat owns focus - Clear hidden virtual modifiers while preserving held keys and Pin behavior Signed-off-by: fufesou * fix(shortcuts): prevent recording non-simultaneous key combinations Build new captures from currently held keys instead of reusing released letters. Preserve the last valid chord after release so it can be saved. Signed-off-by: fufesou * fix(shortcuts): isolate window actions and prevent duplicate Alt presses - Dispatch shortcut actions only to the originating session window. - Clear local modifier state on focus loss without resending modifiers already released to Windows peers, preventing lost following input. - Strengthen the regression assertion against extra Alt keydown events Signed-off-by: fufesou * refact(shortcuts): empty default shortcuts for Cmd+Option+Shift+Del on macOS and iOS Because Del may be ambiguous for new macOS users. Cmd+Option+Shift+Backspace is "Empty Trash without confirmation" shortcut on macOS. Signed-off-by: fufesou * fix: use session peer platform for Ctrl+Alt+Del events Fix Android-to-Linux Ctrl+Alt+Del delivery by using the session's peer platform instead of the desktop global session, which defaults to Windows Signed-off-by: fufesou * fix: hide relative mouse mode shortcuts on Wayland Separate relative mouse mode availability from screenshot support. Filter unsupported actions and defaults while preserving saved bindings Signed-off-by: fufesou * fix: align shortcut availability with platform support - Hide unsupported mobile and web shortcut actions - Enable mobile scaling and Android voice call shortcuts - Use openMonitorInTheSameTab() for display switching - Preserve cursor position when switching displays on desktop and web Signed-off-by: fufesou * fix: prevent held-key shortcut recording bypass Signed-off-by: fufesou * fix: preserve keyboard input after shortcut dialogs Run native clipboard shortcuts on key release and wait for remote modifier releases before dispatching. Release remote modifiers when mobile local UI takes focus, and document the tested iPad Bluetooth Mute shortcut limitation. Signed-off-by: fufesou * fix: preserve remote key state during local shortcuts Keep unrelated held keys pressed for session actions that retain input. Preserve key release when focus or input handling changes, and avoid synthetic Alt taps during shortcut dispatch. Signed-off-by: fufesou * fix: suspend saved relative mouse shortcuts on Wayland Filter the native active binding cache without changing stored configuration, so the binding becomes available again under X11. Signed-off-by: fufesou * fix: exclude unsupported Wayland shortcuts from Flutter matching Keep saved bindings intact while filtering the active config used by the Flutter matcher. Cover stored bindings and enable/pass-through flags in the existing shortcut tests. Signed-off-by: fufesou * fix: retain forwarded key ownership across shortcut repeats Ignore repeat presses for keys already forwarded to the peer, and release ordinary held keys before Show My Cursor enables view-only. Cover both paths with converter-level regressions. Signed-off-by: fufesou * fix: release Flutter-held keys before view-only shortcuts Track ordinary keys forwarded by the Dart matcher paths and await their releases before View Only or Show My Cursor disables input. Keep Mute key ownership and existing empty-cleanup timing unchanged. Signed-off-by: fufesou * fix: filter active Flutter shortcuts by platform capabilities Reuse the Settings action filter for saved bindings while preserving storage, default-seeding behavior and configurable Apple Ctrl+Alt+Del bindings. Signed-off-by: fufesou * fix: filter native shortcut caches for mobile controllers Suspend saved desktop-only actions on Android and iOS and hide unavailable iOS actions without changing persisted bindings. Keep native cache filtering aligned with Flutter platform capabilities. Signed-off-by: fufesou * fix: share filtered shortcut bindings with the Web matcher Pass the active platform config to JavaScript at startup and after settings changes, preserving unsupported saved bindings in local storage. Signed-off-by: fufesou * fix: release Flutter-held keys before focus-changing shortcuts Drain ordinary keys owned by the original session before run-on-key-up actions and native clipboard callbacks. Preserve Mute holds, pending-action cancellation and Web clipboard key-down activation. Document the required Web matcher counterpart in the bridge: rustdesk/rustdesk-web-js@94abe431d60977292803b812f086efaaff5e8a3b Validation: 61 native Flutter tests, 32 Chrome/HTML tests, targeted analysis and Windows debug build. Signed-off-by: fufesou * refact: remove the useless comments Signed-off-by: fufesou * fix: prevent Windows menu activation from Alt-only shortcuts Neutralize the forwarded Alt with a balanced temporary Ctrl press when a consumed local shortcut releases its only modifier in Windows Map mode. Keep physical modifier tracking and normal Alt pass-through unchanged. Add a regression covering both Alt keys, trigger repeats and the eventual physical release. Verified with 36 native shortcut tests and real Windows menu/input checks. Signed-off-by: fufesou * fix: prevent Alt menu activation in all keyboard modes Extend Windows Alt-only shortcut neutralization to Translate and Legacy. Keep the temporary Ctrl packets in Map mode so Legacy modifier synchronization cannot release Alt before Ctrl. Extend the existing regression across keyboard modes and decode scan, virtual and control-key events. Verify 36 native tests and 34 Windows menu replay cases. Signed-off-by: fufesou * fix: initialize outgoing chat before shortcut dispatch Signed-off-by: fufesou * fix: guard Windows Alt release in Flutter legacy shortcuts Signed-off-by: fufesou * fix: coordinate Web shortcut dispatch and Alt modifier cleanup Signed-off-by: fufesou * fix: distinguish AltGraph from ordinary Alt shortcuts Signed-off-by: fufesou * fix: dispatch macOS Web shortcuts without waiting for keyup Signed-off-by: fufesou * fix: support legacy peers in privacy mode shortcut Signed-off-by: fufesou * fix: exclude AltGraph from Flutter shortcut matching Signed-off-by: fufesou * fix: exclude GTK AltGr input from raw shortcut matching Signed-off-by: fufesou * test: trim redundant keyboard shortcut coverage Signed-off-by: fufesou * fix(keyboard): prevent Cmd shortcuts from opening remote Start Signed-off-by: fufesou * fix(shortcuts): guard Windows menu keys after Ctrl/Cmd swapping Signed-off-by: fufesou * fix(shortcuts): align recording and display routing Reject non-Apple AltGraph captures to match runtime filtering. Honor individual-display windows for next, previous, and all-display shortcuts. Signed-off-by: fufesou * fix(shortcuts): reject GTK AltGr chords in recorder Signed-off-by: fufesou * fix(android): restore keyboard focus for modifier-first input Focus the existing FlutterView when a modifier is pressed without native view focus. This restores shortcut-first input after returning to RustDesk while preserving focused inputs and normal key dispatch. Signed-off-by: fufesou * fix(shortcuts): release held keys before keyboard mode changes Drain Flutter-forwarded keys before keyboard-mode shortcut actions so releases use the original encoding. This prevents held keys from remaining down after a Web Input source 2 mode change. Signed-off-by: fufesou * fix(i18n): use sentence case for shortcut labels Signed-off-by: fufesou * fix(mobile): preserve touch modifiers in the input helper Keep touch-selected modifiers visible after hiding the keyboard. Clear physical modifier releases locally in view-only mode so they do not reopen the helper. Signed-off-by: fufesou * fix(web): preserve modifiers across Legacy-to-Map shortcuts Signed-off-by: fufesou * fix(shortcuts): check support before registering all displays view Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: fufesou Co-authored-by: Claude Opus 5.5 --- .gitignore | 2 + AGENTS.md | 24 + .../com/carriez/flutter_hbb/MainActivity.kt | 11 + .../widgets/keyboard_shortcuts/display.dart | 111 ++ .../widgets/keyboard_shortcuts/page_body.dart | 481 +++++ .../keyboard_shortcuts/recording_dialog.dart | 418 +++++ .../keyboard_shortcuts/shortcut_actions.dart | 287 +++ .../shortcut_constants.dart | 122 ++ .../keyboard_shortcuts/shortcut_utils.dart | 208 +++ flutter/lib/common/widgets/toolbar.dart | 291 ++- flutter/lib/consts.dart | 2 + .../desktop_keyboard_shortcuts_page.dart | 66 + .../desktop/pages/desktop_setting_page.dart | 97 +- flutter/lib/desktop/pages/remote_page.dart | 13 + .../lib/desktop/widgets/remote_toolbar.dart | 30 +- .../pages/mobile_keyboard_shortcuts_page.dart | 95 + flutter/lib/mobile/pages/remote_page.dart | 87 +- flutter/lib/mobile/pages/settings_page.dart | 20 + flutter/lib/models/chat_model.dart | 70 +- flutter/lib/models/input_model.dart | 317 +++- .../lib/models/local_flutter_shortcuts.dart | 211 +++ flutter/lib/models/model.dart | 9 + flutter/lib/models/native_model.dart | 3 + flutter/lib/models/shortcut_model.dart | 669 +++++++ flutter/lib/models/web_model.dart | 1 + flutter/lib/web/bridge.dart | 34 + .../fixtures/default_keyboard_shortcuts.json | 11 + .../fixtures/key_up_shortcut_actions.json | 8 + .../test/fixtures/shortcut_key_usb_hid.json | 65 + .../fixtures/supported_shortcut_keys.json | 11 + flutter/test/keyboard_shortcuts_test.dart | 507 ++++++ .../test/local_flutter_shortcuts_test.dart | 566 ++++++ flutter/test/shortcut_model_test.dart | 277 +++ src/flutter.rs | 6 +- src/flutter_ffi.rs | 18 + src/keyboard.rs | 64 +- src/keyboard/shortcuts.rs | 1614 +++++++++++++++++ src/lang/ar.rs | 34 +- src/lang/az.rs | 34 +- src/lang/be.rs | 34 +- src/lang/bg.rs | 34 +- src/lang/ca.rs | 34 +- src/lang/cn.rs | 34 +- src/lang/cs.rs | 34 +- src/lang/da.rs | 34 +- src/lang/de.rs | 34 +- src/lang/el.rs | 34 +- src/lang/en.rs | 9 + src/lang/eo.rs | 34 +- src/lang/es.rs | 34 +- src/lang/et.rs | 34 +- src/lang/eu.rs | 34 +- src/lang/fa.rs | 34 +- src/lang/fi.rs | 34 +- src/lang/fr.rs | 34 +- src/lang/ge.rs | 34 +- src/lang/gl.rs | 34 +- src/lang/gu.rs | 34 +- src/lang/he.rs | 34 +- src/lang/hi.rs | 34 +- src/lang/hr.rs | 34 +- src/lang/hu.rs | 34 +- src/lang/id.rs | 34 +- src/lang/it.rs | 34 +- src/lang/ja.rs | 34 +- src/lang/ko.rs | 34 +- src/lang/kz.rs | 34 +- src/lang/lt.rs | 34 +- src/lang/lv.rs | 34 +- src/lang/ml.rs | 34 +- src/lang/nb.rs | 34 +- src/lang/nl.rs | 34 +- src/lang/pl.rs | 34 +- src/lang/pt_BR.rs | 34 +- src/lang/pt_PT.rs | 34 +- src/lang/ro.rs | 34 +- src/lang/ru.rs | 34 +- src/lang/sc.rs | 34 +- src/lang/sk.rs | 34 +- src/lang/sl.rs | 34 +- src/lang/sq.rs | 34 +- src/lang/sr.rs | 34 +- src/lang/sv.rs | 34 +- src/lang/ta.rs | 34 +- src/lang/template.rs | 34 +- src/lang/th.rs | 34 +- src/lang/tr.rs | 34 +- src/lang/tw.rs | 34 +- src/lang/uk.rs | 32 + src/lang/ur.rs | 34 +- src/lang/vi.rs | 34 +- src/platform/linux.rs | 29 + src/ui_session_interface.rs | 29 +- 93 files changed, 8570 insertions(+), 123 deletions(-) create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/display.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart create mode 100644 flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart create mode 100644 flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart create mode 100644 flutter/lib/models/local_flutter_shortcuts.dart create mode 100644 flutter/lib/models/shortcut_model.dart create mode 100644 flutter/test/fixtures/default_keyboard_shortcuts.json create mode 100644 flutter/test/fixtures/key_up_shortcut_actions.json create mode 100644 flutter/test/fixtures/shortcut_key_usb_hid.json create mode 100644 flutter/test/fixtures/supported_shortcut_keys.json create mode 100644 flutter/test/keyboard_shortcuts_test.dart create mode 100644 flutter/test/local_flutter_shortcuts_test.dart create mode 100644 flutter/test/shortcut_model_test.dart create mode 100644 src/keyboard/shortcuts.rs diff --git a/.gitignore b/.gitignore index f51a5b8cd..07e207d89 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ libsciter.dylib flutter/web/ # libdrmtap is cloned at build time by build.py (not a submodule) /third_party/libdrmtap/ +# Local git worktrees +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index b67303afb..eb8d01b7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,30 @@ workspace member. `base::config::keys` re-exports the handful of keys * Use `spawn_blocking` or dedicated threads for blocking work. * Do not use `std::thread::sleep()` in async code. +## Flutter Rust Bridge + +* Do **not** run `flutter_rust_bridge_codegen` — it requires a specific pinned version that is not easy to set up locally. +* When adding new FFI functions in `src/flutter_ffi.rs`, hand-write the corresponding Dart wrappers instead of regenerating. +* Web bridge (committed): edit `flutter/lib/web/bridge.dart` directly. Follow the existing patterns there for `SyncReturn` / `Future` and the `dart:js` glue. +* Native bridge (`flutter/lib/generated_bridge.dart`, `src/bridge_generated.rs`, `src/bridge_generated.io.rs`): these are gitignored and regenerated by the project's CI codegen. Manually editing them locally is fine for development testing, but those edits do not persist into commits. + +## Web (Flutter Web) Architecture + +Flutter Web in this repo is **not** "Dart compiled to JS via Flutter alone". The runtime is split: + +* **Native targets (Win/Mac/Linux/Android/iOS)**: Rust drives sessions via `flutter_rust_bridge`; Dart only renders UI. +* **Web target**: Rust does **not** run. There is a separate hand-written TypeScript / JavaScript client at `flutter/web/js/` (gitignored — not present in this repo, lives in the maintainer's local tree). It owns connection, codec, keyboard, clipboard, etc. — basically a JS port of the Rust client. The Dart UI talks to it through `flutter/lib/web/bridge.dart`, which uses `dart:js` to call JS-side functions and to register Dart-side callbacks on `window.*`. + +Implications when adding any session-runtime feature (keyboard, clipboard, audio, …): + +* The Rust implementation in `src/` is for **native only**. Don't try to compile it to wasm. +* The matching Web-side logic must be written in TS/JS under `flutter/web/js/src/`. It's a translation of the Rust logic, usually simpler — Web is single-window, so any per-session-id plumbing in Rust collapses to a single global on Web. +* `flutter/lib/web/bridge.dart` is the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes through `bind`. Don't sprinkle `if (isWeb)` runtime branches in shared Dart files to call Web-specific logic — put the platform divergence in the bridge. +* For JS → Dart events (e.g., a Web matcher firing), the convention is: Dart sets `js.context['onFooBar'] = (...) {...}` once at startup (typically in `mainInit`); the JS side calls `window.onFooBar(...)`. See `onLoadAbFinished`, `onLoadGroupFinished` for reference. +* The maintainer cannot easily run `flutter_rust_bridge_codegen`, so when a new FFI function lands in `src/flutter_ffi.rs`: + 1. add the Web counterpart to `flutter/lib/web/bridge.dart` by hand; + 2. note that on the Web target it may need to be a no-op or a JS bridge call rather than a real Rust invocation. + ## Editing Hygiene * Change only what is required. diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index 7225e52e7..f38304083 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -19,6 +19,7 @@ import android.os.Bundle import android.os.Build import android.os.IBinder import android.util.Log +import android.view.KeyEvent import android.view.WindowManager import android.media.MediaCodecInfo import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface @@ -35,6 +36,7 @@ import org.json.JSONArray import org.json.JSONObject import com.hjq.permissions.XXPermissions import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterView import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import kotlin.concurrent.thread @@ -107,6 +109,15 @@ class MainActivity : FlutterActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && + KeyEvent.isModifierKey(event.keyCode) && currentFocus == null) { + // Android can drop modifier-first input when the native view has no focus. + findViewById(FlutterActivity.FLUTTER_VIEW_ID)?.requestFocus() + } + return super.dispatchKeyEvent(event) + } + private fun requestMediaProjection() { val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply { action = ACT_REQUEST_MEDIA_PROJECTION diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/display.dart b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart new file mode 100644 index 000000000..b824bacc2 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart @@ -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? _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? parsed; + if (raw == _cachedRaw) { + parsed = _cachedParsed; + } else { + try { + parsed = jsonDecode(raw) as Map; + } 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().toList() + : const []; + // 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 = []; + 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(); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart new file mode 100644 index 000000000..4d8083d29 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart @@ -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 createState() => + KeyboardShortcutsPageBodyState(); +} + +/// Public state so platform shells can call [resetToDefaultsWithConfirm] from +/// their AppBar action. +class KeyboardShortcutsPageBodyState extends State { + // ----- Persistence helpers ----- + + Map _readJson() { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return {'enabled': false, 'bindings': []}; + try { + final parsed = jsonDecode(raw) as Map; + parsed['bindings'] ??= []; + parsed['enabled'] ??= false; + return parsed; + } catch (_) { + return {'enabled': false, 'bindings': []}; + } + } + + Future _writeJson(Map json) async { + await bind.mainSetLocalOption( + key: kShortcutLocalConfigKey, value: jsonEncode(json)); + // Refresh the matcher cache so writes take effect immediately. On native + // this hits the Rust matcher; on Web the bridge forwards to the JS-side + // matcher in flutter/web/js/. + bind.mainReloadKeyboardShortcuts(); + if (mounted) setState(() {}); + } + + /// Replace the bindings entry for [actionId] with [binding]. If [binding] + /// is null, removes the existing entry. If the user is replacing a + /// conflicting binding, [clearActionId] points at the action whose + /// (now-stale) binding should be removed in the same write. + Future _setBinding( + String actionId, { + Map? binding, + String? clearActionId, + }) async { + final json = _readJson(); + final list = 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 _setEnabled(bool v) async { + await ShortcutModel.setEnabled(v); + if (mounted) setState(() {}); + } + + Future _setPassThrough(bool v) async { + await ShortcutModel.setPassThrough(v); + if (mounted) setState(() {}); + } + + Future _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 _groupsForCurrentPlatform() { + return filterKeyboardShortcutActionGroupsForPlatform( + ShortcutModel.currentPlatformCapabilities(), + ); + } + + // ----- UI handlers ----- + + Future _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 _onClear(KeyboardShortcutActionEntry entry) async { + await _setBinding(entry.id, binding: null); + } + + /// Public — invoked from the platform AppBar action. + Future resetToDefaultsWithConfirm() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(translate('Reset to defaults')), + content: Text(translate('shortcut-reset-confirm-tip')), + actions: [ + dialogButton('Cancel', + onPressed: () => Navigator.of(ctx).pop(false), isOutline: true), + dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)), + ], + ), + ); + if (confirmed == true) { + await _resetToDefaults(); + } + } + + // ----- Build ----- + + @override + Widget build(BuildContext context) { + final enabled = ShortcutModel.isEnabled(); + final theme = Theme.of(context); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (widget.headerBanner != null) ...[ + widget.headerBanner!, + const SizedBox(height: 12), + ], + 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 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, + ), + ), + ); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart new file mode 100644 index 000000000..da9ac3fea --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart @@ -0,0 +1,418 @@ +// 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 binding; + + /// If the chosen combo conflicted with another action, the user chose + /// "Replace" — the caller must clear this action's binding before writing + /// the new one. + final String? clearActionId; + + RecordingResult(this.binding, this.clearActionId); +} + +/// Show the recording dialog. +/// +/// [actionId] is the action being edited (used for the title and to detect +/// "binding to itself" — that's not a conflict). +/// [actionLabel] is the translated, user-facing action name. +/// [existingBindings] is the current bindings list (used for conflict detection). +/// [actionLabelLookup] resolves an actionId to its translated label, used in +/// the conflict warning. +Future showRecordingDialog({ + required BuildContext context, + required String actionId, + required String actionLabel, + required List> existingBindings, + required String Function(String) actionLabelLookup, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _RecordingDialog( + actionId: actionId, + actionLabel: actionLabel, + existingBindings: existingBindings, + actionLabelLookup: actionLabelLookup, + ), + ); +} + +class _RecordingDialog extends StatefulWidget { + final String actionId; + final String actionLabel; + final List> existingBindings; + final String Function(String) actionLabelLookup; + + const _RecordingDialog({ + required this.actionId, + required this.actionLabel, + required this.existingBindings, + required this.actionLabelLookup, + }); + + @override + State<_RecordingDialog> createState() => _RecordingDialogState(); +} + +class _RecordingDialogState extends State<_RecordingDialog> { + final FocusNode _focusNode = FocusNode(); + + // Captured combo. null until the user presses something with a non-modifier. + Set _mods = {}; + String? _key; + + // 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.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; + + final logical = event.logicalKey; + final keyName = shortcutKeyNameForEvent(event); + + // 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 = {}; + 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; + if (!_isMac && + HardwareKeyboard.instance.logicalKeysPressed + .contains(LogicalKeyboardKey.altGraph)) { + _key = null; + _unsupportedKey = LogicalKeyboardKey.altGraph.keyLabel; + return; + } + // Match runtime dispatch: modifiers added after a held letter do not + // produce a new shortcut key-down event. + _key = keyName; + _unsupportedKey = null; + if (keyName == null && !_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; + } + + KeyEventResult _onRawKeyEvent(FocusNode node, RawKeyEvent event) { + final data = event.data; + const gtkMod5Mask = 1 << 7; + // GTK can expose AltGr only through Mod5, without a logical AltGraph key. + if (event is RawKeyDownEvent && + data is RawKeyEventDataLinux && + data.keyHelper is GtkKeyHelper && + (data.modifiers & gtkMod5Mask) != 0) { + setState(() { + _key = null; + _unsupportedKey = LogicalKeyboardKey.altGraph.keyLabel; + }); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _onSave() { + if (_key == null || !_hasRequiredPrefix) return; + final ordered = canonicalShortcutModsForSave(_mods); + final binding = { + '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 = []; + 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; + 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, + onKey: _onRawKeyEvent, + 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), + ], + ); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart new file mode 100644 index 000000000..88d82fb91 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart @@ -0,0 +1,287 @@ +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 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 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 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(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 allActionEntries( + Iterable 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 filterKeyboardShortcutActionGroupsForPlatform( + ShortcutPlatformCapabilities cap, +) { + bool allowed(String id) { + if (cap.excludedActions.contains(id)) return false; + if (!cap.includeFullscreenShortcut && + id == kShortcutActionToggleFullscreen) { + return false; + } + if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) { + return false; + } + if (!cap.includeRelativeMouseModeShortcut && + 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.includeVoiceCallShortcut && id == kShortcutActionToggleVoiceCall) { + return false; + } + return true; + } + + final out = []; + for (final group in kKeyboardShortcutActionGroups) { + final filteredChildren = []; + 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; +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart new file mode 100644 index 000000000..aa162c5d1 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart @@ -0,0 +1,122 @@ +/// 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'; + +/// Actions that move keyboard focus to another session or a dialog. They run when the +/// key that fired them is released, not when it is pressed, so the matcher +/// that consumed the press also sees its repeats and its release. On Linux a +/// legacy-mode session matches in Dart while a map-mode session matches in +/// Rust, and neither knows the other's fired keys. Mirrors `runs_on_release` +/// in `src/keyboard/shortcuts.rs`; both are checked against +/// `flutter/test/fixtures/key_up_shortcut_actions.json`. +const kShortcutActionsRunOnKeyUp = { + kShortcutActionCloseTab, + kShortcutActionSwitchTabNext, + kShortcutActionSwitchTabPrev, + kShortcutActionRestartRemote, + kShortcutActionSwitchSides, + kShortcutActionToggleChat, +}; +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 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> kDefaultShortcutBindings = [ + for (final entry in >[ + [kShortcutActionSendCtrlAltDel, 'delete'], + [kShortcutActionToggleFullscreen, 'enter'], + [kShortcutActionSwitchDisplayNext, 'arrow_right'], + [kShortcutActionSwitchDisplayPrev, 'arrow_left'], + [kShortcutActionScreenshot, 'p'], + [kShortcutActionToggleShowRemoteCursor, 'm'], + // The tested iPad/Bluetooth keyboard setup did not deliver the complete + // Cmd+Option+Shift+S chord, also in Safari. Other physical keyboards are + // unverified; Mute can be rebound to a chord that reaches the app. + [kShortcutActionToggleMute, 's'], + [kShortcutActionToggleBlockInput, 'i'], + [kShortcutActionToggleChat, 'c'], + ]) + { + 'action': entry[0], + 'mods': const ['primary', 'alt', 'shift'], + 'key': entry[1], + }, +]; diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart new file mode 100644 index 000000000..c27573029 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart @@ -0,0 +1,208 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'shortcut_constants.dart'; + +List canonicalShortcutModsForSave(Set mods) { + return [ + if (mods.contains('primary')) 'primary', + if (mods.contains('ctrl')) 'ctrl', + if (mods.contains('alt')) 'alt', + if (mods.contains('shift')) 'shift', + ]; +} + +List> shortcutBindingMapsFrom(dynamic rawBindings) { + if (rawBindings is! Iterable) return >[]; + final bindings = >[]; + for (final raw in rawBindings) { + if (raw is! Map) continue; + final mods = raw['mods']; + if (raw['action'] is! String || + raw['key'] is! String || + mods is! List || + mods.any((mod) => + !const {'primary', 'ctrl', 'alt', 'shift'}.contains(mod))) { + continue; + } + final binding = {}; + 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 shortcutModSetFrom(dynamic rawMods) { + if (rawMods is! Iterable) return {}; + return rawMods.whereType().toSet(); +} + +/// Whether enabling shortcuts should seed the default bindings. Only a config +/// that has never held a bindings list is seeded; an empty list means the +/// user cleared every binding and must stay empty. +bool shouldSeedDefaultShortcutBindings(Map config) => + !config.containsKey('bindings'); + +bool isSwitchTabShortcutAction(String? actionId) { + return actionId == kShortcutActionSwitchTabNext || + actionId == kShortcutActionSwitchTabPrev; +} + +/// USB HID usage (keyboard page 0x07) of every key accepted as a shortcut, +/// mapped to the canonical key name used in saved bindings. +const Map _kUsbHidKeyNames = { + 0x04: 'a', 0x05: 'b', 0x06: 'c', 0x07: 'd', 0x08: 'e', 0x09: 'f', + 0x0A: 'g', 0x0B: 'h', 0x0C: 'i', 0x0D: 'j', 0x0E: 'k', 0x0F: 'l', + 0x10: 'm', 0x11: 'n', 0x12: 'o', 0x13: 'p', 0x14: 'q', 0x15: 'r', + 0x16: 's', 0x17: 't', 0x18: 'u', 0x19: 'v', 0x1A: 'w', 0x1B: 'x', + 0x1C: 'y', 0x1D: 'z', + 0x1E: 'digit1', 0x1F: 'digit2', 0x20: 'digit3', 0x21: 'digit4', + 0x22: 'digit5', 0x23: 'digit6', 0x24: 'digit7', 0x25: 'digit8', + 0x26: 'digit9', 0x27: 'digit0', + 0x28: 'enter', 0x2A: 'backspace', 0x2B: 'tab', 0x2C: 'space', + 0x3A: 'f1', 0x3B: 'f2', 0x3C: 'f3', 0x3D: 'f4', 0x3E: 'f5', 0x3F: 'f6', + 0x40: 'f7', 0x41: 'f8', 0x42: 'f9', 0x43: 'f10', 0x44: 'f11', 0x45: 'f12', + 0x49: 'insert', 0x4A: 'home', 0x4B: 'page_up', 0x4C: 'delete', + 0x4D: 'end', 0x4E: 'page_down', 0x4F: 'arrow_right', 0x50: 'arrow_left', + 0x51: 'arrow_down', 0x52: 'arrow_up', + // Numpad Enter shares the "enter" name with the main Return key, as in the + // Rust matcher (`Return | KpReturn`) and the Web matcher (`NumpadEnter`). + 0x58: 'enter', +}; + +/// Map a [PhysicalKeyboardKey] to the canonical key name used in saved +/// bindings, or `null` for keys we don't accept as shortcuts. +/// +/// Bindings name physical key positions (US layout names), whatever the +/// active keyboard layout: the native matcher sees the key through its USB +/// HID usage (`rdev::usb_hid_key_from_code` -> `event_to_key_name` in +/// `src/keyboard/shortcuts.rs`) and the Web matcher through +/// `KeyboardEvent.code` (`flutter/web/js/src/shortcut_matcher.ts`). Parity +/// is enforced against `flutter/test/fixtures/shortcut_key_usb_hid.json` by +/// a Dart test and the Rust `usb_hid_keys_match_fixture` test. +String? physicalKeyName(PhysicalKeyboardKey k) { + final usage = k.usbHidUsage; + if (usage >> 16 != 0x07) return null; + return _kUsbHidKeyNames[usage & 0xFFFF]; +} + +/// The key name a [KeyEvent] records or matches as. +String? shortcutKeyNameForEvent(KeyEvent e) => physicalKeyName(e.physicalKey); + +/// 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 includeRelativeMouseModeShortcut; + 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 includeVoiceCallShortcut; + final Set excludedActions; + + const ShortcutPlatformCapabilities({ + required this.includeFullscreenShortcut, + required this.includeScreenshotShortcut, + required this.includeRelativeMouseModeShortcut, + 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.includeVoiceCallShortcut, + this.excludedActions = const {}, + }); +} + +List> filterDefaultBindingsForPlatform( + Iterable bindings, + ShortcutPlatformCapabilities cap, +) { + final filtered = >[]; + for (final binding in shortcutBindingMapsFrom(bindings)) { + final action = binding['action'] as String?; + if (cap.excludedActions.contains(action)) continue; + // Compact Apple keyboards can label Backspace as Delete, making this + // default ambiguous. Using Backspace also conflicts with Finder's + // "Empty Trash without confirmation" shortcut on macOS. + // Leave it unbound; users can still record their own shortcut. + if ((defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS) && + action == kShortcutActionSendCtrlAltDel) { + continue; + } + if (!cap.includeFullscreenShortcut && + action == kShortcutActionToggleFullscreen) { + continue; + } + if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) { + continue; + } + if (!cap.includeRelativeMouseModeShortcut && + 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.includeVoiceCallShortcut && + action == kShortcutActionToggleVoiceCall) { + continue; + } + filtered.add(binding); + } + return filtered; +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index c3896b097..0ab0a4aa3 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -11,6 +11,7 @@ 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'; import 'package:url_launcher/url_launcher.dart'; @@ -92,16 +93,77 @@ bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) { versionCmp(pi.version, '1.4.8') >= 0); } +/// 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 = [ + kShortcutActionSendCtrlAltDel, + kShortcutActionRestartRemote, + kShortcutActionInsertLock, + kShortcutActionToggleBlockInput, + kShortcutActionSwitchSides, + kShortcutActionRefresh, + kShortcutActionScreenshot, + kShortcutActionResetCanvas, + kShortcutActionSendClipboardKeystrokes, +]; + +const _kToolbarViewStyleActionIds = [ + kShortcutActionViewModeOriginal, + kShortcutActionViewModeAdaptive, + kShortcutActionViewModeCustom, +]; + +const _kToolbarImageQualityActionIds = [ + kShortcutActionImageQualityBest, + kShortcutActionImageQualityBalanced, + kShortcutActionImageQualityLow, +]; + +const _kToolbarCodecActionIds = [ + kShortcutActionCodecAuto, + kShortcutActionCodecVp8, + kShortcutActionCodecVp9, + kShortcutActionCodecAv1, + kShortcutActionCodecH264, + kShortcutActionCodecH265, +]; + +const _kToolbarCursorActionIds = [ + kShortcutActionToggleShowRemoteCursor, + kShortcutActionToggleFollowRemoteCursor, + kShortcutActionToggleFollowRemoteWindow, + kShortcutActionToggleZoomCursor, +]; + +const _kToolbarDisplayToggleActionIds = [ + kShortcutActionToggleQualityMonitor, + kShortcutActionToggleMute, + kShortcutActionToggleEnableFileCopyPaste, + kShortcutActionToggleDisableClipboard, + kShortcutActionToggleLockAfterSessionEnd, + kShortcutActionToggleTrueColor, +]; + +const _kToolbarKeyboardToggleActionIds = [ + 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) { @@ -123,20 +185,73 @@ class TRadioMenu { final T value; final T groupValue; final ValueChanged? 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? 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 _registerToggleMenuShortcuts( + FFI ffi, + List menus, { + List 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> _registerRadioMenuShortcuts( + FFI ffi, + List> menus, { + List 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( @@ -337,6 +452,17 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { final isDefaultConn = ffi.connType == ConnType.defaultConn; final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland; + // 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 v = []; // elevation if (isDefaultConn && @@ -404,7 +530,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { return; } await sendClipboardKeystrokes(); - })); + }, + actionId: kShortcutActionSendClipboardKeystrokes)); } if (isDefaultConn && isWaylandPeer && @@ -439,7 +566,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { 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 @@ -515,7 +643,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text('${translate("Insert Ctrl + Alt + Del")}'), - onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)), + onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId), + actionId: kShortcutActionSendCtrlAltDel), ); } // restart @@ -528,7 +657,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { TTextMenu( child: Text(translate('Restart remote device')), onPressed: () => - showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager)), + showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager), + actionId: kShortcutActionRestartRemote), ); } // insertLock @@ -536,7 +666,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('Insert Lock')), - onPressed: () => bind.sessionLockScreen(sessionId: sessionId)), + onPressed: () => bind.sessionLockScreen(sessionId: sessionId), + actionId: kShortcutActionInsertLock), ); } // blockUserInput @@ -554,7 +685,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { sessionId: sessionId, value: '${blockInput.value ? 'un' : ''}block-input'); blockInput.value = !blockInput.value; - })); + }, + actionId: kShortcutActionToggleBlockInput)); } // switchSides if (isDefaultConn && @@ -566,13 +698,15 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => - showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager))); + showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager), + actionId: kShortcutActionSwitchSides)); } // refresh if (pi.version.isNotEmpty) { v.add(TTextMenu( child: Text(translate('Refresh')), onPressed: () => sessionRefreshVideo(sessionId, pi), + actionId: kShortcutActionRefresh, )); } // record @@ -595,7 +729,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ) ], ), - onPressed: () => ffi.recordingModel.toggle())); + onPressed: () => ffi.recordingModel.toggle(), + actionId: kShortcutActionToggleRecording)); } // to-do: @@ -612,6 +747,14 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: ffi.ffiModel.timerScreenshot != null ? null : () { + // Live cooldown check: the menu rebuilds onPressed=null + // whenever toolbarControls runs and finds timerScreenshot + // != null, but the keyboard-shortcut callback holds onto + // the originally-enabled closure across cooldown periods + // (toolbarControls only re-runs on menu open). Without + // this guard the second shortcut press during the 30s + // cooldown still fires sessionTakeScreenshot. + if (ffi.ffiModel.timerScreenshot != null) return; if (pi.currentDisplay == kAllDisplayValue) { msgBox( sessionId, @@ -629,6 +772,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { }); } }, + actionId: kShortcutActionScreenshot, )); } } @@ -639,6 +783,17 @@ List 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; } @@ -653,23 +808,26 @@ Future>> toolbarViewStyle( .then((_) => ffi.canvasModel.updateViewStyle()); } - return [ + return _registerRadioMenuShortcuts(ffi, [ TRadioMenu( child: Text(translate('Scale original')), value: kRemoteViewStyleOriginal, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionViewModeOriginal), TRadioMenu( child: Text(translate('Scale adaptive')), value: kRemoteViewStyleAdaptive, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionViewModeAdaptive), TRadioMenu( child: Text(translate('Scale custom')), value: kRemoteViewStyleCustom, groupValue: groupValue, - onChanged: onChanged) - ]; + onChanged: onChanged, + actionId: kShortcutActionViewModeCustom) + ], ownedActionIds: _kToolbarViewStyleActionIds); } Future>> toolbarImageQuality( @@ -681,22 +839,25 @@ Future>> toolbarImageQuality( await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value); } - return [ + return _registerRadioMenuShortcuts(ffi, [ TRadioMenu( child: Text(translate('Good image quality')), value: kRemoteImageQualityBest, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionImageQualityBest), TRadioMenu( child: Text(translate('Balanced')), value: kRemoteImageQualityBalanced, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionImageQualityBalanced), TRadioMenu( child: Text(translate('Optimize reaction time')), value: kRemoteImageQualityLow, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionImageQualityLow), TRadioMenu( child: Text(translate('Custom')), value: kRemoteImageQualityCustom, @@ -706,7 +867,7 @@ Future>> toolbarImageQuality( customImageQualityDialog(ffi.sessionId, id, ffi); }, ), - ]; + ], ownedActionIds: _kToolbarImageQualityActionIds); } Future>> toolbarCodec( @@ -733,7 +894,10 @@ Future>> toolbarCodec( } final visible = codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]); - if (!visible) return []; + if (!visible) { + return _registerRadioMenuShortcuts(ffi, [], + ownedActionIds: _kToolbarCodecActionIds); + } onChanged(String? value) async { if (value == null) return; await bind.sessionPeerOption( @@ -741,12 +905,14 @@ Future>> toolbarCodec( bind.sessionChangePreferCodec(sessionId: sessionId); } - TRadioMenu radio(String label, String value, bool enabled) { + TRadioMenu radio( + String label, String value, bool enabled, String actionId) { return TRadioMenu( child: Text(label), value: value, groupValue: groupValue, - onChanged: enabled ? onChanged : null); + onChanged: enabled ? onChanged : null, + actionId: actionId); } var autoLabel = translate('Auto'); @@ -754,14 +920,14 @@ Future>> 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> toolbarCursor( @@ -786,6 +952,7 @@ Future> 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; @@ -822,6 +989,7 @@ Future> 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); @@ -850,6 +1018,7 @@ Future> 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); @@ -867,6 +1036,7 @@ Future> 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); @@ -875,7 +1045,8 @@ Future> toolbarCursor( }, )); } - return v; + return _registerToggleMenuShortcuts(ffi, v, + ownedActionIds: _kToolbarCursorActionIds); } Future> toolbarDisplayToggle( @@ -891,6 +1062,7 @@ Future> 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); @@ -904,6 +1076,7 @@ Future> 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); @@ -928,6 +1101,7 @@ Future> toolbarDisplayToggle( sessionId: sessionId, arg: kOptionEnableFileCopyPaste); v.add(TToggleMenu( value: value, + actionId: kShortcutActionToggleEnableFileCopyPaste, onChanged: enabled ? (value) { if (value == null) return; @@ -946,6 +1120,7 @@ Future> toolbarDisplayToggle( if (ffiModel.viewOnly) value = true; v.add(TToggleMenu( value: value, + actionId: kShortcutActionToggleDisableClipboard, onChanged: enabled ? (value) { if (value == null) return; @@ -962,6 +1137,7 @@ Future> toolbarDisplayToggle( bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); v.add(TToggleMenu( value: value, + actionId: kShortcutActionToggleLockAfterSessionEnd, onChanged: enabled ? (value) { if (value == null) return; @@ -1014,6 +1190,7 @@ Future> 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); @@ -1038,7 +1215,8 @@ Future> toolbarDisplayToggle( }, child: Text(translate('View Mode')))); } - return v; + return _registerToggleMenuShortcuts(ffi, v, + ownedActionIds: _kToolbarDisplayToggleActionIds); } var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1)); @@ -1166,6 +1344,7 @@ List 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')))); } @@ -1231,10 +1410,52 @@ List 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) { + // Rebuild only the triggered action's menu, so permission/capability changes + // and temporary disabled states take effect without opening the toolbar. + void register(List actions, FutureOr Function() refresh) { + ffi.shortcutModel.registerRefresher(actions, () async { + if (!context.mounted || ffi.closed) { + ffi.shortcutModel.clear(); + return; + } + await refresh(); + }); + } + + register([ + ..._kToolbarOwnedActionIds, + if (!(isDesktop || isWeb)) kShortcutActionToggleRecording, + ], () => toolbarControls(context, id, ffi)); + register(_kToolbarKeyboardToggleActionIds, () => toolbarKeyboardToggles(ffi)); + register(_kToolbarCursorActionIds, () => toolbarCursor(context, id, ffi)); + register(_kToolbarDisplayToggleActionIds, + () => toolbarDisplayToggle(context, id, ffi)); + register(_kToolbarViewStyleActionIds, () => toolbarViewStyle(context, id, ffi)); + register(_kToolbarImageQualityActionIds, + () => toolbarImageQuality(context, id, ffi)); + register(_kToolbarCodecActionIds, () => toolbarCodec(context, id, 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) { diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 9f7d3dc36..9dff432db 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -4,6 +4,8 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; +export 'common/widgets/keyboard_shortcuts/shortcut_constants.dart'; + const int kMaxVirtualDisplayCount = 4; const int kAllVirtualDisplay = -1; diff --git a/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart b/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart new file mode 100644 index 000000000..3eed92b94 --- /dev/null +++ b/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart @@ -0,0 +1,66 @@ +// flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart +// +// Desktop shell for the Keyboard Shortcuts configuration page. Users land +// here from the General settings tab. The page exposes: +// * A top-level enable/disable toggle (mirrors the General-tab toggle — +// same JSON key, same semantics). +// * A grouped, scrollable list of actions, each with a current binding and +// edit / clear icons. +// * An AppBar "Reset to defaults" action with a confirmation dialog. +// +// All edits write back to LocalConfig under [kShortcutLocalConfigKey] in the +// canonical {enabled, bindings:[{action,mods,key}]} shape that the Rust and +// Web matchers consume. +// +// The body — group definitions, JSON I/O, conflict-replace flow, +// recording-dialog round-trip — lives in +// `common/widgets/keyboard_shortcuts/page_body.dart` and is shared with the +// mobile shell at `mobile/pages/mobile_keyboard_shortcuts_page.dart`. + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../common.dart'; +import '../../common/widgets/keyboard_shortcuts/page_body.dart'; + +class DesktopKeyboardShortcutsPage extends StatefulWidget { + const DesktopKeyboardShortcutsPage({Key? key}) : super(key: key); + + @override + State createState() => + _DesktopKeyboardShortcutsPageState(); +} + +class _DesktopKeyboardShortcutsPageState + extends State { + final GlobalKey _bodyKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final foregroundColor = + AppBarTheme.of(context).titleTextStyle?.color ?? Colors.white; + return Scaffold( + appBar: AppBar( + title: Text(translate('Keyboard shortcuts')), + actions: [ + TextButton.icon( + style: TextButton.styleFrom(foregroundColor: foregroundColor), + 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, + ), + ); + } +} diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 82f038ca6..4560b3d69 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -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:get/get.dart'; import 'package:provider/provider.dart'; @@ -419,11 +422,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 { @@ -2657,6 +2699,8 @@ Widget _OptionCheckBox( bool isServer = true, bool Function()? optGetter, Future Function(String, bool)? optSetter, + // Optional widget rendered between the label and the trailing space. + Widget? trailing, }) { getOpt() => optGetter != null ? optGetter() @@ -2700,11 +2744,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), @@ -3111,6 +3167,37 @@ class _CountDownButtonState extends State<_CountDownButton> { } } +// Tappable row that pushes the shortcut configuration page. +class _ShortcutsConfigureRow extends StatelessWidget { + // ignore: unused_element + const _ShortcutsConfigureRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const DesktopKeyboardShortcutsPage(), + )); + }, + child: Row( + children: [ + Expanded( + child: Text(translate('Configure shortcuts...')), + ), + Icon(Icons.arrow_forward_ios, + size: 16, color: disabledTextColor(context, true)) + .marginOnly(right: 4), + ], + ).marginOnly( + left: _kCheckBoxLeftMargin, + top: 6, + bottom: 6, + ), + ); + } +} + //#endregion //#region dialogs diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 55277caa0..ecd9080e9 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -17,6 +17,7 @@ import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; import '../../models/input_model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; @@ -162,6 +163,18 @@ class _RemotePageState extends State _ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.recordingModel .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, _ffi); + registerSessionShortcutActions(_ffi, + tabController: widget.tabController, + toolbarState: widget.toolbarState); + registerToolbarShortcuts(context, widget.id, _ffi); + } }); _ffi.canvasModel.initializeEdgeScrollFallback(this); _ffi.start( diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 19b3fa985..9863f4e0c 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/display.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -1318,8 +1319,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); diff --git a/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart b/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart new file mode 100644 index 000000000..ea44429cf --- /dev/null +++ b/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart @@ -0,0 +1,95 @@ +// flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart +// +// Mobile shell for the Keyboard Shortcuts configuration page. Mirrors +// `desktop/pages/desktop_keyboard_shortcuts_page.dart` but with a touch- +// friendly layout (ListTile rows instead of dense rows) and a hint banner +// that explains the recording flow only works with a physical keyboard. +// +// All actual logic — group definitions, JSON I/O, conflict-replace flow, +// recording-dialog round-trip, "Reset to defaults" — lives in the shared +// `common/widgets/keyboard_shortcuts/page_body.dart`. This file only +// supplies the AppBar, the AppBar action, and the platform hint banner. +// +// Mobile keyboard detection limitation: Flutter has no reliable +// "is a physical keyboard attached?" API on iOS or Android. Soft keyboards +// don't generate the `KeyDownEvent`s the recording dialog listens for, so +// in practice the dialog only does anything useful when the user actually +// has a hardware keyboard plugged in (USB / Bluetooth / Smart Connector). +// For V1 we don't try to detect attachment — we just surface the +// requirement as an in-page hint instead of disabling the Edit button. + +import 'package:flutter/material.dart'; + +import '../../common.dart'; +import '../../common/widgets/keyboard_shortcuts/page_body.dart'; + +class MobileKeyboardShortcutsPage extends StatefulWidget { + const MobileKeyboardShortcutsPage({Key? key}) : super(key: key); + + @override + State createState() => + _MobileKeyboardShortcutsPageState(); +} + +class _MobileKeyboardShortcutsPageState + extends State { + final GlobalKey _bodyKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text(translate('Keyboard shortcuts')), + actions: [ + IconButton( + tooltip: translate('Reset to defaults'), + onPressed: () => + _bodyKey.currentState?.resetToDefaultsWithConfirm(), + icon: const Icon(Icons.restore), + ), + ], + ), + body: KeyboardShortcutsPageBody( + key: _bodyKey, + compact: false, + editButtonHint: translate('shortcut-mobile-physical-keyboard-tip'), + headerBanner: _PhysicalKeyboardHintBanner(theme: theme), + ), + ); + } +} + +/// A muted info banner shown above the master toggle on mobile. We can't +/// reliably detect whether a physical keyboard is attached, so instead of +/// disabling the Edit button we surface the requirement up front. +class _PhysicalKeyboardHintBanner extends StatelessWidget { + final ThemeData theme; + const _PhysicalKeyboardHintBanner({required this.theme}); + + @override + Widget build(BuildContext context) { + final color = theme.colorScheme.primary.withOpacity(0.08); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, + size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + translate('shortcut-mobile-physical-keyboard-tip'), + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 7d4bf9c3b..9da67ab8b 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -21,6 +21,7 @@ import '../../common/widgets/remote_input.dart'; import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; import '../widgets/custom_scale_widget.dart'; @@ -40,6 +41,32 @@ void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) { } } +bool handleMobileLocalKeyEvent(KeyEvent event, + {required bool remoteFocused, required InputModel inputModel}) { + if (event is KeyUpEvent && inputModel.isViewOnly) { + // View-only skips normal modifier cleanup; stale physical keys would + // otherwise look like touch selections in the input helper. + inputModel.handleKeyUpEventModifiers(event); + } + if (!remoteFocused && + event is KeyUpEvent && + const [ + PhysicalKeyboardKey.controlLeft, + PhysicalKeyboardKey.controlRight, + PhysicalKeyboardKey.altLeft, + PhysicalKeyboardKey.altRight, + PhysicalKeyboardKey.shiftLeft, + PhysicalKeyboardKey.shiftRight, + PhysicalKeyboardKey.metaLeft, + PhysicalKeyboardKey.metaRight, + ].contains(event.physicalKey)) { + // Local UI may consume key-ups while the remote input path still owns + // the modifiers pressed before focus moved. + inputModel.handleKeyEvent(event); + } + return false; +} + class RemotePage extends StatefulWidget { RemotePage( {Key? key, @@ -107,6 +134,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { }); WakelockManager.enable(_uniqueKey); _physicalFocusNode.requestFocus(); + HardwareKeyboard.instance.addHandler(_handleLocalKeyEvent); gFFI.inputModel.listenToMouse(true); gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId); keyboardSubscription = @@ -122,6 +150,19 @@ class _RemotePageState extends State with WidgetsBindingObserver { } _disableAndroidSoftKeyboard( isKeyboardVisible: keyboardVisibilityController.isVisible); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, gFFI); + // Mobile has no DesktopTabController, so tab-switch shortcuts will + // log a no-handler debug line if a user binds one. + registerSessionShortcutActions(gFFI); + _registerMobileChatShortcut(); + registerToolbarShortcuts(context, widget.id, gFFI); + } }); WidgetsBinding.instance.addObserver(this); @@ -142,6 +183,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { @override Future dispose() async { + HardwareKeyboard.instance.removeHandler(_handleLocalKeyEvent); WidgetsBinding.instance.removeObserver(this); // Close the session up-front. `gFFI.close()` below only calls `sessionClose` // after several awaits (canvas save, image update, the `enable_soft_keyboard` @@ -253,10 +295,10 @@ class _RemotePageState extends State with WidgetsBindingObserver { if (isIOS) { _iosKeyboardWorkaroundTimer?.cancel(); _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () { - if (!mounted) return; + if (!mounted || gFFI.chatModel.inputNode.hasFocus) return; _physicalFocusNode.unfocus(); _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () { - if (!mounted) return; + if (!mounted || gFFI.chatModel.inputNode.hasFocus) return; _physicalFocusNode.requestFocus(); }); }); @@ -538,6 +580,9 @@ class _RemotePageState extends State with WidgetsBindingObserver { ); } + bool _handleLocalKeyEvent(KeyEvent event) => handleMobileLocalKeyEvent(event, + remoteFocused: _physicalFocusNode.hasPrimaryFocus, inputModel: inputModel); + Widget getRawPointerAndKeyBody(Widget child) { final ffiModel = Provider.of(context); return RawPointerMouseRegion( @@ -659,7 +704,6 @@ class _RemotePageState extends State with WidgetsBindingObserver { !gFFI.inputModel.relativeMouseMode.value; Widget getBodyForMobile() { - final keyboardIsVisible = keyboardVisibilityController.isVisible; return Container( color: MyTheme.canvasColor, child: Stack(children: () { @@ -670,9 +714,14 @@ class _RemotePageState extends State with WidgetsBindingObserver { right: 10, child: QualityMonitor(gFFI.qualityMonitorModel), ), - KeyHelpTools( - keyboardIsVisible: keyboardIsVisible, - showGestureHelp: _showGestureHelp), + Obx(() { + final chatKeyboardRequested = + gFFI.chatModel.softKeyboardRequested.value; + return KeyHelpTools( + keyboardIsVisible: keyboardVisibilityController.isVisible && + (_showEdit || chatKeyboardRequested), + showGestureHelp: _showGestureHelp); + }), SizedBox( width: 0, height: 0, @@ -809,9 +858,18 @@ class _RemotePageState extends State with WidgetsBindingObserver { }(); } - onPressedTextChat(String id) { + void _registerMobileChatShortcut() { + if (!isMobile) return; + gFFI.shortcutModel.register(kShortcutActionToggleChat, + () => onPressedTextChat(widget.id, requestSoftKeyboard: false)); + } + + onPressedTextChat(String id, {bool requestSoftKeyboard = true}) { + if (gFFI.chatModel.chatWindowOverlayEntry == null) { + setState(() => _showEdit = false); + } gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID)); - gFFI.chatModel.toggleChatOverlay(); + gFFI.chatModel.toggleChatOverlay(requestSoftKeyboard: requestSoftKeyboard); } showChatOptions(String id) async { @@ -979,12 +1037,13 @@ class _KeyHelpToolsState extends State { @override Widget build(BuildContext context) { - final hasModifierOn = inputModel.ctrl || - inputModel.alt || - inputModel.shift || - inputModel.command; - - if (!_pin && !hasModifierOn && !widget.requestShow) { + final keyboard = HardwareKeyboard.instance; + // Keep touch selections visible without physical modifiers opening the bar. + final hasTouchModifier = (inputModel.ctrl && !keyboard.isControlPressed) || + (inputModel.alt && !keyboard.isAltPressed) || + (inputModel.shift && !keyboard.isShiftPressed) || + (inputModel.command && !keyboard.isMetaPressed); + if (!_pin && !hasTouchModifier && !widget.requestShow) { gFFI.cursorModel .keyHelpToolsVisibilityChanged(null, widget.keyboardIsVisible); return Offstage(); diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index 198613a1b..de5e55812 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -18,8 +18,10 @@ import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../widgets/deploy_dialog.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 { @@ -897,6 +899,24 @@ class _SettingsState extends State 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')), + trailing: Icon(Icons.arrow_forward_ios), + 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')), diff --git a/flutter/lib/models/chat_model.dart b/flutter/lib/models/chat_model.dart index d79c9f070..ab70dd330 100644 --- a/flutter/lib/models/chat_model.dart +++ b/flutter/lib/models/chat_model.dart @@ -64,8 +64,11 @@ class ChatModel with ChangeNotifier { RxBool isWindowFocus = true.obs; BlockableOverlayState _blockableOverlayState = BlockableOverlayState(); final Rx _voiceCallStatus = Rx(VoiceCallStatus.notStarted); + final RxBool _softKeyboardRequested = false.obs; + bool _usingPhysicalChatInput = false; Rx get voiceCallStatus => _voiceCallStatus; + RxBool get softKeyboardRequested => _softKeyboardRequested; TextEditingController textController = TextEditingController(); RxInt mobileUnreadSum = 0.obs; @@ -80,6 +83,8 @@ class ChatModel with ChangeNotifier { @override void dispose() { + inputNode.removeListener(_updateChatInputControl); + _restoreChatInputControl(); textController.dispose(); super.dispose(); } @@ -194,13 +199,18 @@ class ChatModel with ChangeNotifier { } } - showChatWindowOverlay({Offset? chatInitPos}) { + showChatWindowOverlay( + {Offset? chatInitPos, bool requestSoftKeyboard = true}) { if (chatWindowOverlayEntry != null) return; isWindowFocus.value = true; _blockableOverlayState.setMiddleBlocked(true); final overlayState = _blockableOverlayState.state; if (overlayState == null) return; + if (isMobile) { + _softKeyboardRequested.value = requestSoftKeyboard; + gFFI.invokeMethod("enable_soft_keyboard", true); + } if (isMobile && !gFFI.chatModel.currentKey.isOut && // not in remote page gFFI.chatModel.latestReceivedKey != null) { @@ -209,7 +219,8 @@ class ChatModel with ChangeNotifier { } final overlay = OverlayEntry(builder: (context) { return Listener( - onPointerDown: (_) { + onPointerDown: (event) { + _requestChatSoftKeyboard(event); if (!isWindowFocus.value) { isWindowFocus.value = true; _blockableOverlayState.setMiddleBlocked(true); @@ -223,6 +234,10 @@ class ChatModel with ChangeNotifier { }); overlayState.insert(overlay); chatWindowOverlayEntry = overlay; + if (isMobile && !requestSoftKeyboard) { + inputNode.addListener(_updateChatInputControl); + _updateChatInputControl(); + } requestChatInputFocus(); } @@ -231,6 +246,9 @@ class ChatModel with ChangeNotifier { _blockableOverlayState.setMiddleBlocked(false); chatWindowOverlayEntry!.remove(); chatWindowOverlayEntry = null; + _softKeyboardRequested.value = false; + inputNode.removeListener(_updateChatInputControl); + _restoreChatInputControl(); return; } } @@ -239,13 +257,14 @@ class ChatModel with ChangeNotifier { ((!(isDesktop || isWebDesktop) && chatIconOverlayEntry == null) || chatWindowOverlayEntry == null); - toggleChatOverlay({Offset? chatInitPos}) { + toggleChatOverlay({Offset? chatInitPos, bool requestSoftKeyboard = true}) { if (_isChatOverlayHide()) { - gFFI.invokeMethod("enable_soft_keyboard", true); + if (!isMobile) gFFI.invokeMethod("enable_soft_keyboard", true); if (!(isDesktop || isWebDesktop)) { showChatIconOverlay(); } - showChatWindowOverlay(chatInitPos: chatInitPos); + showChatWindowOverlay( + chatInitPos: chatInitPos, requestSoftKeyboard: requestSoftKeyboard); } else { hideChatIconOverlay(); hideChatWindowOverlay(); @@ -522,6 +541,47 @@ class ChatModel with ChangeNotifier { }); } + void _requestChatSoftKeyboard(PointerDownEvent event) { + if (!isMobile || _softKeyboardRequested.value) return; + inputNode.context?.visitAncestorElements((element) { + if (element.widget is! TextField) return true; + final box = element.findRenderObject(); + if (box is! RenderBox) return false; + final bounds = box.localToGlobal(Offset.zero) & box.size; + if (!bounds.contains(event.position)) return false; + _softKeyboardRequested.value = true; + _restoreChatInputControl(); + // Reconnect so the platform receives the keyboard type instead of none. + inputNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + scheduleMicrotask(() { + if (chatWindowOverlayEntry != null && _softKeyboardRequested.value) { + inputNode.requestFocus(); + } + }); + return false; + }); + } + + void _updateChatInputControl() { + final physicalChatFocused = chatWindowOverlayEntry != null && + inputNode.hasFocus && + !_softKeyboardRequested.value; + if (!physicalChatFocused) { + _restoreChatInputControl(); + return; + } + if (_usingPhysicalChatInput) return; + _usingPhysicalChatInput = true; + // Keep the platform text-input connection for physical editing, without IME UI. + TextInput.setInputControl(null); + } + + void _restoreChatInputControl() { + if (!_usingPhysicalChatInput) return; + _usingPhysicalChatInput = false; + TextInput.restorePlatformInputControl(); + } + void onVoiceCallWaiting() { _voiceCallStatus.value = VoiceCallStatus.waitingForResponse; } diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 98055b202..995dd36b2 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -15,8 +15,11 @@ import 'package:get/get.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../models/state_model.dart'; +import '../common/widgets/keyboard_shortcuts/shortcut_utils.dart'; import 'input_modifier_utils.dart'; +import 'local_flutter_shortcuts.dart'; import 'relative_mouse_model.dart'; import '../common.dart'; import '../consts.dart'; @@ -416,6 +419,14 @@ class InputModel { final ToReleaseRawKeys toReleaseRawKeys = ToReleaseRawKeys(); final ToReleaseKeys toReleaseKeys = ToReleaseKeys(); + late final _localFlutterShortcuts = LocalFlutterShortcutDispatcher( + onTriggered: (action) { + final ffi = parent.target; + if (ffi != null && !ffi.closed) { + ffi.shortcutModel.onTriggered(action); + } + }, + ); // trackpad var _trackpadLastDelta = Offset.zero; @@ -737,7 +748,12 @@ class InputModel { } KeyEventResult handleRawKeyEvent(RawKeyEvent e) { - if (isViewOnly) return KeyEventResult.handled; + if (_localFlutterShortcuts.tryDispatchRaw(e)) return KeyEventResult.handled; + if (isViewOnly) { + if (isWeb && !isInputSourceFlutter) return KeyEventResult.ignored; + _tryDispatchRawFlutterShortcut(e, releaseModifiers: false); + return KeyEventResult.handled; + } if (isViewCamera) return KeyEventResult.handled; if (!isInputSourceFlutter) { if (isDesktop) { @@ -746,6 +762,7 @@ class InputModel { return KeyEventResult.ignored; } } + _syncFlutterShortcutModifiersAfterViewOnly(e.physicalKey, raw: true); if (_relativeMouse.handleRawKeyEvent(e)) { return KeyEventResult.handled; @@ -813,8 +830,13 @@ class InputModel { // * Currently mobile does not enable map mode if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) { + _restoreRawFlutterShortcutModifiers(e); mapKeyboardModeRaw(e, iosCapsLock); } else { + if (_tryDispatchRawFlutterShortcut(e)) return KeyEventResult.handled; + _restoreRawFlutterShortcutModifiers(e, mapMode: false); + _trackFlutterShortcutKey(e.physicalKey, e.logicalKey, + down: e is RawKeyDownEvent && !e.repeat, up: e is RawKeyUpEvent); legacyKeyboardModeRaw(e); } @@ -822,7 +844,12 @@ class InputModel { } KeyEventResult handleKeyEvent(KeyEvent e) { - if (isViewOnly) return KeyEventResult.handled; + if (_localFlutterShortcuts.tryDispatch(e)) return KeyEventResult.handled; + if (isViewOnly) { + if (isWeb && !isInputSourceFlutter) return KeyEventResult.ignored; + _tryDispatchFlutterShortcut(e, releaseModifiers: false); + return KeyEventResult.handled; + } if (isViewCamera) return KeyEventResult.handled; if (!isInputSourceFlutter) { if (isDesktop) { @@ -831,6 +858,10 @@ class InputModel { return KeyEventResult.ignored; } } + _syncFlutterShortcutModifiersAfterViewOnly(e.physicalKey); + if (isWeb && _tryDispatchFlutterShortcut(e)) { + return KeyEventResult.handled; + } if (isWindows || isLinux) { // Ignore meta keys. Because flutter window will loose focus if meta key is pressed. if (e.physicalKey == PhysicalKeyboardKey.metaLeft || @@ -911,6 +942,14 @@ class InputModel { final isDesktopAndMapMode = isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode); if (isMobileAndMapMode || isDesktopAndMapMode) { + _restoreFlutterShortcutModifiers(e); + // Native map input is matched and tracked by Rust before forwarding. + if (isWeb) { + _trackFlutterShortcutKey(e.physicalKey, e.logicalKey, + down: e is KeyDownEvent || e is KeyRepeatEvent, + up: e is KeyUpEvent, + mapMode: true); + } // FIXME: e.character is wrong for dead keys, eg: ^ in de newKeyboardMode( e.character ?? '', @@ -919,12 +958,285 @@ class InputModel { e is KeyDownEvent || e is KeyRepeatEvent, iosCapsLock); } else { + if (!isWeb && _tryDispatchFlutterShortcut(e)) return KeyEventResult.handled; + _restoreFlutterShortcutModifiers(e, mapMode: false); + _trackFlutterShortcutKey(e.physicalKey, e.logicalKey, + down: e is KeyDownEvent, up: e is KeyUpEvent); legacyKeyboardMode(e); } return KeyEventResult.handled; } + void _trackFlutterShortcutKey( + PhysicalKeyboardKey physical, LogicalKeyboardKey logical, + {required bool down, required bool up, bool mapMode = false}) { + if ((!down && !up) || (down && !keyboardPerm)) return; + _localFlutterShortcuts.recordForwardedKey(physical, + down: down, + release: () => mapMode + ? bind.sessionHandleFlutterKeyEvent( + sessionId: sessionId, + character: '', + usbHid: physical.usbHidUsage & 0xFFFF, + lockModes: _buildLockModes(false), + downOrUp: false, + ) + : bind.sessionInputKey( + sessionId: sessionId, + name: physicalKeyMap[physical.usbHidUsage] ?? + logicalKeyMap[logical.keyId] ?? + logical.keyLabel, + down: false, + press: false, + alt: false, + ctrl: false, + shift: false, + command: false, + )); + } + + bool _tryDispatchFlutterShortcut(KeyEvent e, {bool releaseModifiers = true}) { + final keyboard = HardwareKeyboard.instance; + return _localFlutterShortcuts.tryDispatch( + e, + viewOnly: isViewOnly, + match: () { + // AltGraph is text input on Windows/Linux; Apple Option stays bindable. + if (!(isMacOS || isIOS || isWebOnMacOs) && + keyboard.logicalKeysPressed.contains(LogicalKeyboardKey.altGraph)) { + return null; + } + return _matchFlutterShortcut(e.physicalKey, + ctrlPressed: keyboard.isControlPressed, + altPressed: keyboard.isAltPressed, + shiftPressed: keyboard.isShiftPressed, + commandPressed: keyboard.isMetaPressed); + }, + releaseModifiers: + releaseModifiers ? _releaseFlutterShortcutModifiers : null, + ); + } + + bool _tryDispatchRawFlutterShortcut(RawKeyEvent e, + {bool releaseModifiers = true}) { + return _localFlutterShortcuts.tryDispatchRaw( + e, + viewOnly: isViewOnly, + match: () { + final data = e.data; + const gtkMod5Mask = 1 << 7; + // GTK's Mod5 (used by AltGr) is missing from the ordinary modifier flags. + if (data is RawKeyEventDataLinux && + data.keyHelper is GtkKeyHelper && + (data.modifiers & gtkMod5Mask) != 0) { + return null; + } + return _matchFlutterShortcut(e.physicalKey, + ctrlPressed: e.isControlPressed, + altPressed: e.isAltPressed, + shiftPressed: e.isShiftPressed, + commandPressed: e.isMetaPressed); + }, + releaseModifiers: releaseModifiers + ? () => _releaseFlutterShortcutModifiers(raw: true) + : null, + ); + } + + Future _releaseFlutterShortcutModifiers({bool raw = false}) async { + final releases = >[]; + // Release only on the remote: the modifiers are still physically held. + final pressed = raw + ? RawKeyboard.instance.keysPressed + : HardwareKeyboard.instance.logicalKeysPressed; + final swapCtrlCmd = peerPlatform == kPeerPlatformWindows && + bind.sessionGetToggleOptionSync( + sessionId: sessionId, arg: 'allow_swap_key'); + final neutralizeMenu = _needsShortcutMenuGuard(pressed, swapCtrlCmd); + if (neutralizeMenu) + await _sendShortcutMenuControl(true, pressed, swapCtrlCmd); + for (final entry in { + LogicalKeyboardKey.controlLeft: PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight: PhysicalKeyboardKey.controlRight, + LogicalKeyboardKey.altLeft: PhysicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight: PhysicalKeyboardKey.altRight, + LogicalKeyboardKey.shiftLeft: PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight: PhysicalKeyboardKey.shiftRight, + LogicalKeyboardKey.metaLeft: PhysicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight: PhysicalKeyboardKey.metaRight, + }.entries) { + if (!pressed.contains(entry.key)) continue; + if (isWebDesktop) { + // The action can switch from Legacy to Map while modifiers stay held. + _localFlutterShortcuts.recordReleasedModifiers([entry.value]); + } + if (isWebDesktop && keyboardMode == kKeyMapMode) { + releases.add(bind.sessionHandleFlutterKeyEvent( + sessionId: sessionId, + character: '', + usbHid: entry.value.usbHidUsage & 0xFFFF, + lockModes: _buildLockModes(false), + downOrUp: false, + )); + } else { + final label = physicalKeyMap[entry.value.usbHidUsage] ?? entry.key.keyLabel; + releases.add(bind.sessionInputKey( + sessionId: sessionId, + name: label, + down: false, + press: false, + alt: false, + ctrl: false, + shift: false, + command: false, + )); + } + } + await Future.wait(releases).whenComplete(() => neutralizeMenu + ? _sendShortcutMenuControl(false, pressed, swapCtrlCmd) + : null); + } + + bool _needsShortcutMenuGuard( + Set pressed, bool swapCtrlCmd) { + if (peerPlatform != kPeerPlatformWindows) return false; + // Web's legacy key-name API does not swap the key itself. + final swapped = + swapCtrlCmd && (!isWebDesktop || keyboardMode == kKeyMapMode); + return pressed.any({ + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + swapped + ? LogicalKeyboardKey.controlLeft + : LogicalKeyboardKey.metaLeft, + swapped + ? LogicalKeyboardKey.controlRight + : LogicalKeyboardKey.metaRight, + }.contains) && + !pressed.any({ + swapped + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.controlLeft, + swapped + ? LogicalKeyboardKey.metaRight + : LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + }.contains); + } + + Future _sendShortcutMenuControl( + bool down, Set pressed, bool swapCtrlCmd) { + // Choose the pre-swap key that reaches Windows as Ctrl. + if (isWebDesktop) { + return bind.sessionHandleFlutterKeyEvent( + sessionId: sessionId, + character: '', + usbHid: (swapCtrlCmd + ? PhysicalKeyboardKey.metaLeft + : PhysicalKeyboardKey.controlLeft) + .usbHidUsage & + 0xFFFF, + lockModes: _buildLockModes(false), + downOrUp: down, + ); + } + return bind.sessionInputKey( + sessionId: sessionId, + name: swapCtrlCmd ? 'Meta' : 'VK_CONTROL', + down: down, + press: false, + // Legacy reconciles modifiers before key-down. Keep Alt/Meta held until + // Ctrl is down, otherwise reconciliation can activate menus or Start. + alt: down && + pressed.any({LogicalKeyboardKey.altLeft, LogicalKeyboardKey.altRight} + .contains), + ctrl: down && + pressed.any({ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight + }.contains), + shift: false, + command: down && + pressed.any({ + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight + }.contains), + ); + } + + void _syncFlutterShortcutModifiersAfterViewOnly(PhysicalKeyboardKey incoming, + {bool raw = false}) { + if (!isInputSourceFlutter) return; + final pressed = raw + ? RawKeyboard.instance.physicalKeysPressed + : HardwareKeyboard.instance.physicalKeysPressed; + if (!_localFlutterShortcuts.resumeModifiersAfterViewOnly(incoming, pressed)) { + return; + } + ctrl = pressed.contains(PhysicalKeyboardKey.controlLeft) || + pressed.contains(PhysicalKeyboardKey.controlRight); + alt = pressed.contains(PhysicalKeyboardKey.altLeft) || + pressed.contains(PhysicalKeyboardKey.altRight); + shift = pressed.contains(PhysicalKeyboardKey.shiftLeft) || + pressed.contains(PhysicalKeyboardKey.shiftRight); + command = pressed.contains(PhysicalKeyboardKey.metaLeft) || + pressed.contains(PhysicalKeyboardKey.metaRight); + } + + void _restoreFlutterShortcutModifiers(KeyEvent e, {bool mapMode = true}) { + final keys = _localFlutterShortcuts.takeModifiersToRestore( + e, HardwareKeyboard.instance.physicalKeysPressed); + if (!mapMode) return; + for (final key in keys) { + newKeyboardMode('', key.usbHidUsage & 0xFFFF, true, false); + } + } + + void _restoreRawFlutterShortcutModifiers(RawKeyEvent e, {bool mapMode = true}) { + final keys = _localFlutterShortcuts.takeRawModifiersToRestore( + e, RawKeyboard.instance.physicalKeysPressed); + if (!mapMode) return; + for (final key in keys) { + newKeyboardMode('', key.usbHidUsage & 0xFFFF, true, false); + } + } + + String? _matchFlutterShortcut(PhysicalKeyboardKey key, + {required bool ctrlPressed, + required bool altPressed, + required bool shiftPressed, + required bool commandPressed}) { + if (isViewCamera) return null; + final keyName = physicalKeyName(key); + if (keyName == null) return null; + final config = ShortcutModel.config(active: true); + if (!config.enabled || config.passThrough) return null; + final mods = []; + if (isMacOS || isIOS || isWebOnMacOs) { + if (commandPressed) mods.add('primary'); + if (ctrlPressed) mods.add('ctrl'); + } else if (ctrlPressed) { + mods.add('primary'); + } + if (altPressed) mods.add('alt'); + if (shiftPressed) mods.add('shift'); + for (final binding in config.bindings) { + final action = binding['action']; + final key = binding['key']; + final bindingMods = + canonicalShortcutModsForSave(shortcutModSetFrom(binding['mods'])); + if (action is String && + key == keyName && + bindingMods.isNotEmpty && + listEquals(bindingMods, mods)) { + return action; + } + } + return null; + } + /// Send Key Event void newKeyboardMode( String character, int usbHid, bool down, bool iosCapsLock) { @@ -1113,6 +1425,7 @@ class InputModel { /// Reset key modifiers to false, including [shift], [ctrl], [alt] and [command]. void resetModifiers() { shift = ctrl = alt = command = false; + if (parent.target?.closed == true) _localFlutterShortcuts.clear(); } /// Modify the given modifier map [evt] based on current modifier key status. diff --git a/flutter/lib/models/local_flutter_shortcuts.dart b/flutter/lib/models/local_flutter_shortcuts.dart new file mode 100644 index 000000000..956eb5570 --- /dev/null +++ b/flutter/lib/models/local_flutter_shortcuts.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../common/widgets/keyboard_shortcuts/shortcut_constants.dart'; + +class LocalFlutterShortcutDispatcher { + final void Function(String) onTriggered; + // Keys whose press fired a shortcut and whose release has not arrived yet. + // Ownership is physical: it is shared by every session, because a shortcut + // can switch focus or close its own session before the key is released, and + // it is never dropped with a session. A key whose release is missed heals + // itself: its next press is consumed as a repeat and that release removes it. + static final _firedKeys = {}; + // Actions waiting for the release of the key that fired them, with the + // session that fired them. See [kShortcutActionsRunOnKeyUp]. + static final _keyUpActions = + {}; + static final _clipboardModifierReleases = + )>{}; + final _releasedModifiers = {}; + final _forwardedKeyReleases = + Function()>{}; + bool _viewOnlyShortcutPending = false; + int _generation = 0; + + LocalFlutterShortcutDispatcher({required this.onTriggered}); + + /// Drops this session's pending actions and modifier replay. Fired keys + /// stay owned until their release, whichever session receives it. + void clear() { + _keyUpActions.removeWhere((_, pending) => identical(pending.$1, this)); + _clipboardModifierReleases + .removeWhere((_, pending) => identical(pending.$1, this)); + _releasedModifiers.clear(); + _forwardedKeyReleases.clear(); + _viewOnlyShortcutPending = false; + _generation++; + } + + @visibleForTesting + static void resetFiredKeys() { + _firedKeys.clear(); + _keyUpActions.clear(); + _clipboardModifierReleases.clear(); + } + + void recordReleasedModifiers(Iterable keys) { + _releasedModifiers.addAll(keys); + } + + void recordForwardedKey(PhysicalKeyboardKey key, + {required bool down, required Future Function() release}) { + if (_isModifier(key)) return; + if (down) { + _forwardedKeyReleases.putIfAbsent(key, () => release); + } else { + _forwardedKeyReleases.remove(key); + } + } + + bool resumeModifiersAfterViewOnly( + PhysicalKeyboardKey incoming, Set pressed) { + if (!_viewOnlyShortcutPending) return false; + _viewOnlyShortcutPending = false; + _releasedModifiers + .addAll(pressed.where((key) => key != incoming && _isModifier(key))); + return true; + } + + List takeModifiersToRestore( + KeyEvent event, Set pressed) => + _takeModifiersToRestore(event.physicalKey, + event is KeyDownEvent || event is KeyRepeatEvent, pressed); + + List takeRawModifiersToRestore( + RawKeyEvent event, Set pressed) => + _takeModifiersToRestore( + event.physicalKey, event is RawKeyDownEvent, pressed); + + List _takeModifiersToRestore( + PhysicalKeyboardKey incoming, + bool down, + Set pressed) { + _releasedModifiers.removeWhere((key) => !pressed.contains(key)); + if (!down || _isModifier(incoming)) return []; + final keys = _releasedModifiers.toList(); + _releasedModifiers.clear(); + return keys; + } + + bool _isModifier(PhysicalKeyboardKey key) { + // HID keyboard modifier usages are contiguous. + return key.usbHidUsage >= PhysicalKeyboardKey.controlLeft.usbHidUsage && + key.usbHidUsage <= PhysicalKeyboardKey.metaRight.usbHidUsage; + } + + bool tryDispatch( + KeyEvent event, { + bool viewOnly = false, + String? Function()? match, + Future Function()? releaseModifiers, + }) => + _tryDispatch( + event.physicalKey, + down: event is KeyDownEvent, + up: event is KeyUpEvent, + viewOnly: viewOnly, + match: match, + releaseModifiers: releaseModifiers, + ); + + bool tryDispatchRaw( + RawKeyEvent event, { + bool viewOnly = false, + String? Function()? match, + Future Function()? releaseModifiers, + }) => + _tryDispatch( + event.physicalKey, + down: event is RawKeyDownEvent && !event.repeat, + up: event is RawKeyUpEvent, + viewOnly: viewOnly, + match: match, + releaseModifiers: releaseModifiers, + ); + + bool _tryDispatch( + PhysicalKeyboardKey key, { + required bool down, + required bool up, + required bool viewOnly, + String? Function()? match, + Future Function()? releaseModifiers, + }) { + if (up) { + if (!_firedKeys.remove(key)) return false; + final pending = _keyUpActions.remove(key); + final release = _clipboardModifierReleases.remove(key)?.$2; + if (pending != null) { + final (owner, action) = pending; + unawaited( + owner._trigger(action, release == null ? null : () => release)); + } + return true; + } + // Safari can omit the previous Cmd-held keyup. A fresh browser keydown + // starts a new press; KeyRepeatEvent must still belong to the old chord. + if (kIsWeb && defaultTargetPlatform == TargetPlatform.macOS && down) { + _firedKeys.remove(key); + } + if (_firedKeys.contains(key)) return true; + if (!down) return false; + final action = match?.call(); + if (action == null) return false; + _firedKeys.add(key); + if (viewOnly) _viewOnlyShortcutPending = true; + // macOS browsers can omit letter keyups while Cmd is held. Like the JS + // input source, dispatch on keydown there; remote cleanup still runs first. + // Web clipboard reads also need the key-down's browser user activation. + final runOnKeyUp = + !(kIsWeb && defaultTargetPlatform == TargetPlatform.macOS) && + (kShortcutActionsRunOnKeyUp.contains(action) || + (!kIsWeb && action == kShortcutActionSendClipboardKeystrokes)); + if (runOnKeyUp) { + _keyUpActions[key] = (this, action); + } else { + _keyUpActions.remove(key); + } + if (runOnKeyUp && + action == kShortcutActionSendClipboardKeystrokes && + releaseModifiers != null) { + // The release can precede completion of the asynchronous FFI calls. + final release = Future.sync(releaseModifiers); + _clipboardModifierReleases[key] = (this, release); + unawaited(_trigger(null, () => release)); + } else { + unawaited(_trigger(runOnKeyUp ? null : action, releaseModifiers)); + } + return true; + } + + /// Releases the remote modifiers, then runs [action] unless it is null or + /// this session was cleared meanwhile. + Future _trigger( + String? action, Future Function()? releaseModifiers) async { + final generation = _generation; + try { + if (releaseModifiers != null) await releaseModifiers(); + if (action == null || generation != _generation) return; + if ((action == kShortcutActionToggleViewOnly || + action == kShortcutActionToggleShowMyCursor || + action == kShortcutActionKeyboardModeMap || + action == kShortcutActionKeyboardModeTranslate || + action == kShortcutActionKeyboardModeLegacy || + kShortcutActionsRunOnKeyUp.contains(action) || + (!kIsWeb && action == kShortcutActionSendClipboardKeystrokes)) && + _forwardedKeyReleases.isNotEmpty) { + // Focus changes and view-only can keep physical keyups from the peer. + // Mode changes can encode later keyups differently from their keydowns. + final releases = _forwardedKeyReleases.values.toList(); + _forwardedKeyReleases.clear(); + await Future.wait(releases.map((release) => Future.sync(release))); + } + if (generation == _generation) onTriggered(action); + } catch (e, st) { + debugPrint('Local shortcut failed for $action: $e\n$st'); + } + } +} diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index f1abb8e36..20e71f10d 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -21,6 +21,7 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; @@ -469,6 +470,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'); } @@ -4048,6 +4054,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 @@ -4077,6 +4084,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, @@ -4386,6 +4394,7 @@ class FFI { ffiModel.pi.currentDisplay); } imageModel.callbacksOnFirstImage.clear(); + shortcutModel.clear(); await imageModel.update(null); cursorModel.clear(); ffiModel.clear(); diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index d56d58f38..a5307fb5f 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -59,6 +59,9 @@ class PlatformFFI { final _toAndroidChannel = const MethodChannel('mChannel'); RustdeskImpl get ffiBind => _ffiBind; + @visibleForTesting + void setBindingForTesting(RustdeskImpl binding) => _ffiBind = binding; + F3? _session_get_rgba; static get localeName => Platform.localeName; diff --git a/flutter/lib/models/shortcut_model.dart b/flutter/lib/models/shortcut_model.dart new file mode 100644 index 000000000..d0822dde3 --- /dev/null +++ b/flutter/lib/models/shortcut_model.dart @@ -0,0 +1,669 @@ +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 '../common/widgets/keyboard_shortcuts/shortcut_actions.dart'; +import '../common/widgets/toolbar.dart' + show allowDisplaySwitchInPrivacyMode, showVirtualDisplayMenu; +import '../consts.dart'; +import '../desktop/widgets/remote_toolbar.dart' show ToolbarState; +import 'chat_model.dart' show ChatModel, MessageKey, 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 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 { + static WeakReference? _activeWebModel; + + final WeakReference parent; + final Map _callbacks = {}; + final Map _refreshers = {}; + + ShortcutModel(this.parent); + + /// Called by toolbar / menu builders to register what to do when the + /// matched shortcut fires. + void register(String actionId, ShortcutCallback callback) { + if (parent.target?.closed != false) return; + _callbacks[actionId] = callback; + _activeWebModel = WeakReference(this); + } + + void unregister(String actionId) { + _callbacks.remove(actionId); + } + + void registerRefresher(List actionIds, ShortcutCallback refresh) { + if (parent.target?.closed != false) return; + for (final actionId in actionIds) { + _refreshers[actionId] = refresh; + } + } + + void clear() { + _callbacks.clear(); + _refreshers.clear(); + if (identical(_activeWebModel?.target, this)) { + _activeWebModel = null; + } + } + + static void onWebTriggered(String actionId) { + final model = _activeWebModel?.target; + if (model != null) { + model.onTriggered(actionId); + } else { + debugPrint('shortcut_triggered: no active web shortcut model'); + } + } + + /// Called by the session event listener when a `shortcut_triggered` event + /// arrives for this session. + /// + /// The matcher has already consumed the chord. With no handler the action + /// is unavailable in this session (e.g. screenshot on a peer without + /// support), and the chord is a deliberate no-op: a bound chord always + /// belongs to RustDesk and is never forwarded to the remote. + void onTriggered(String actionId) { + if (parent.target?.closed != false) return; + unawaited(Future.sync(() async { + final refresh = _refreshers[actionId]; + if (refresh != null) { + await refresh(); + if (!identical(_refreshers[actionId], refresh)) return; + } + if (parent.target?.closed != false) return; + final cb = _callbacks[actionId]; + if (cb != null) { + await cb(); + } else { + debugPrint('shortcut_triggered: no handler for $actionId'); + } + }).catchError((e, st) { + debugPrint('shortcut_triggered: handler failed for $actionId: $e\n$st'); + })); + } + + static String _configRaw = ''; + static ShortcutConfig _config = ShortcutConfig.parse(''); + static ShortcutConfig _activeConfig = _config; + + /// The stored config, parsed once per distinct stored value. The Flutter + /// matcher reads it on every key press, so a key press costs one config + /// read and no JSON parsing while the config is unchanged. + /// Runtime matching requests [active] to exclude platform-hidden bindings + /// while retaining the stored config for other platforms. + static ShortcutConfig config({bool active = false}) { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw != _configRaw) { + _config = ShortcutConfig.parse(raw); + _activeConfig = _config.forPlatform(currentPlatformCapabilities()); + _configRaw = raw; + } + return active ? _activeConfig : _config; + } + + /// Read the bindings JSON from LocalConfig. Returns a copy the caller may + /// edit. + static List> readBindings() => [ + for (final binding in config().bindings) + Map.of(binding) + ]; + + static bool isEnabled() => config().enabled; + + static bool isPassThrough() => config().passThrough; + + /// 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 setPassThrough(bool v) async { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + Map json = {}; + if (raw.isNotEmpty) { + try { + json = jsonDecode(raw) as Map; + } 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, an empty list included. Refreshes the matcher cache so + /// the change takes effect immediately (Rust on native, JS via the bridge + /// on Web). + static Future setEnabled(bool v) async { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + Map json = {}; + if (raw.isNotEmpty) { + try { + json = jsonDecode(raw) as Map; + } catch (_) { + json = {}; + } + } + json['enabled'] = v; + if (v && shouldSeedDefaultShortcutBindings(json)) { + json['bindings'] = filterDefaultBindingsForPlatform( + jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List, + currentPlatformCapabilities(), + ); + } else if (json.containsKey('bindings')) { + json['bindings'] = shortcutBindingMapsFrom(json['bindings']); + } + 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: 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`). + /// * Voice Call: iOS has no audio capture implementation; Web bridge throws + /// `UnimplementedError` for both `sessionRequestVoiceCall` and + /// `sessionCloseVoiceCall`. + static ShortcutPlatformCapabilities currentPlatformCapabilities() { + final desktopLayout = isDesktop || isWeb; + return ShortcutPlatformCapabilities( + includeFullscreenShortcut: desktopLayout, + includeScreenshotShortcut: isDesktop, + includeRelativeMouseModeShortcut: + isDesktop && !(isLinux && bind.mainCurrentIsWayland()), + includeTabShortcuts: isDesktop, + includeToolbarShortcut: desktopLayout, + includeCloseTabShortcut: isDesktop, + includeSwitchSidesShortcut: isDesktop, + includeRecordingShortcut: !isWeb && !isIOS, + includeResetCanvasShortcut: isMobile, + includePinToolbarShortcut: desktopLayout, + includeViewModeShortcut: true, + includeVoiceCallShortcut: !isWeb && !isIOS, + excludedActions: Set.unmodifiable({ + if (!isDesktop) ...[ + kShortcutActionSwitchDisplayAll, + kShortcutActionToggleEnableFileCopyPaste, + ], + if (isMobile) ...[ + kShortcutActionKeyboardModeLegacy, + kShortcutActionKeyboardModeMap, + kShortcutActionKeyboardModeTranslate, + kShortcutActionToggleShowMyCursor, + kShortcutActionToggleZoomCursor, + ], + }), + ); + } +} + +/// One parsed view of the `keyboard-shortcuts` LocalConfig value. Malformed +/// or missing input parses as disabled with no bindings. +class ShortcutConfig { + final bool enabled; + final bool passThrough; + final List> bindings; + + const ShortcutConfig._(this.enabled, this.passThrough, this.bindings); + + ShortcutConfig forPlatform(ShortcutPlatformCapabilities capabilities) { + final visible = allActionEntries( + filterKeyboardShortcutActionGroupsForPlatform(capabilities)) + .map((entry) => entry.id) + .toSet(); + final hidden = allActionEntries(kKeyboardShortcutActionGroups) + .map((entry) => entry.id) + .where((id) => !visible.contains(id)) + .toSet(); + return ShortcutConfig._( + enabled, + passThrough, + List.unmodifiable(bindings.where((b) => !hidden.contains(b['action']))), + ); + } + + static ShortcutConfig parse(String raw) { + if (raw.isEmpty) return const ShortcutConfig._(false, false, []); + try { + final parsed = jsonDecode(raw) as Map; + return ShortcutConfig._( + parsed['enabled'] == true, + parsed['pass_through'] == true, + List.unmodifiable(shortcutBindingMapsFrom(parsed['bindings'])), + ); + } catch (_) { + return const ShortcutConfig._(false, false, []); + } + } +} + +/// Register the default-bound shortcut actions that aren't already wired by +/// `toolbarControls(...)` (which handles things like Ctrl+Alt+Shift+Del and the +/// screenshot action). Called once per session from the desktop / mobile +/// remote page, after the toolbar registrations have run. +/// +/// 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` 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(); + }); + } + + void openDisplay(int display, PeerInfo pi, {bool updateCursorPos = true}) { + if (isDesktop && + pi.isSupportMultiDisplay && + bind.sessionGetDisplaysAsIndividualWindows(sessionId: sessionId) == + 'Y') { + openMonitorInNewTabOrWindow(display, ffi.id, pi); + } else { + openMonitorInTheSameTab(display, ffi, pi, + updateCursorPos: updateCursorPos); + } + } + + // 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 privacyMode = PrivacyModeState.find(ffi.id).value; + if (privacyMode.isNotEmpty && + !allowDisplaySwitchInPrivacyMode(pi, privacyMode)) { + return; + } + 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; + } + openDisplay(next, pi, updateCursorPos: isMobile); + } + + 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. + // + // For same-tab view, use `openMonitorInTheSameTab` 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; + final privacyMode = PrivacyModeState.find(ffi.id).value; + if (privacyMode.isNotEmpty && + !allowDisplaySwitchInPrivacyMode(pi, privacyMode)) { + return; + } + if (!pi.isSupportMultiDisplay || pi.displays.length <= 1) return; + if (pi.currentDisplay == kAllDisplayValue) return; + openDisplay(kAllDisplayValue, 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.changeCurrentKey(MessageKey(ffi.id, ChatModel.clientModeID)); + 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; iOS has no audio capture implementation. + if (!isWeb && !isIOS) { + 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 { + if (!ffi.ffiModel.keyboard || + ffi.ffiModel.viewOnly || + !bind.sessionIsKeyboardModeSupported( + sessionId: sessionId, mode: mode) || + (ffi.ffiModel.pi.isWayland && mode != kKeyMapMode)) { + return; + } + if (isInputSourceFlutter && isDesktop) { + final modeOnly = bind.sessionIsKeyboardModeSupported( + sessionId: sessionId, mode: kKeyMapMode) + ? kKeyMapMode + : kKeyLegacyMode; + if (mode != modeOnly) return; + } + 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, () { + if (ffi.connType != ConnType.defaultConn || + !showVirtualDisplayMenu(ffi) || + PrivacyModeState.find(ffi.id).value.isNotEmpty || + (ffi.ffiModel.pi.isAmyuniIdd && + ffi.ffiModel.pi.amyuniVirtualDisplayCount == 0)) { + return; + } + 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?; + 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; + } + + bool checkPrivacyModeAllowed(String implKey) { + final ffiModel = ffi.ffiModel; + final pi = ffiModel.pi; + final privacyMode = PrivacyModeState.find(ffi.id).value; + if (ffi.connType != ConnType.defaultConn || + ffiModel.viewOnly || + (privacyMode.isEmpty && + (!pi.features.privacyMode || !ffiModel.keyboard))) { + return false; + } + if (privacyMode == implKey) return true; + if (ffiModel.permissions['privacy_mode'] == false) return false; + if (allowDisplaySwitchInPrivacyMode(pi, implKey) || + (pi.currentDisplay == 0 && + !bind.sessionIsMultiUiSession(sessionId: sessionId))) { + return true; + } + msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info', + 'Please switch to Display 1 first', '', ffi.dialogManager); + return false; + } + + // 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, () { + if (ffi.ffiModel.pi + .platformAdditions[kPlatformAdditionsSupportedPrivacyModeImpl] == + null) { + // Older peers use the legacy option instead of a named implementation. + if (!checkPrivacyModeAllowed(kPrivacyModeImplMag)) return; + bind.sessionToggleOption(sessionId: sessionId, value: 'privacy-mode'); + return; + } + final implKey = findPrivacyImpl('privacy_mode_impl_mag_tip'); + if (implKey == null) return; + if (!checkPrivacyModeAllowed(implKey)) 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; + if (!checkPrivacyModeAllowed(implKey)) 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 { + if (!ffi.ffiModel.keyboard || ffi.ffiModel.viewOnly) return; + 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(); + }); + } +} diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index 2941fbacf..f27b6eec8 100644 --- a/flutter/lib/models/web_model.dart +++ b/flutter/lib/models/web_model.dart @@ -153,6 +153,7 @@ class PlatformFFI { gFFI.dialogManager.dismissAll(); closeConnection(); }; + await _ffiBind.mainInit(appDir: ''); context.callMethod('init'); version = getByName('version'); window.onContextMenu.listen((event) { diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index f8ae3b931..a8b1f21e2 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import 'dart:html' as html; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; 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'); @@ -991,6 +992,28 @@ class RustdeskImpl { ])); } + // Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to + // use the same platform-filtered bindings as the Flutter matcher, while + // keeping unsupported saved bindings in LocalStorage. + void mainReloadKeyboardShortcuts({dynamic hint}) { + final config = ShortcutModel.config(active: true); + js.context.callMethod('reloadShortcuts', [ + jsonEncode({ + 'enabled': config.enabled, + 'pass_through': config.passThrough, + 'bindings': config.bindings, + }) + ]); + } + + // 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']); @@ -1237,6 +1260,17 @@ class RustdeskImpl { } Future mainInit({required String appDir, dynamic hint}) { + // JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/ + // shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a + // binding fires; route it to the active session's ShortcutModel. + // Web uses a JS-side connection, so the event does not arrive through the + // native session event stream. + js.context['onShortcutTriggered'] = (dynamic action) { + if (action is String) { + ShortcutModel.onWebTriggered(action); + } + }; + mainReloadKeyboardShortcuts(); return Future.value(); } diff --git a/flutter/test/fixtures/default_keyboard_shortcuts.json b/flutter/test/fixtures/default_keyboard_shortcuts.json new file mode 100644 index 000000000..121fd615b --- /dev/null +++ b/flutter/test/fixtures/default_keyboard_shortcuts.json @@ -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"} +] diff --git a/flutter/test/fixtures/key_up_shortcut_actions.json b/flutter/test/fixtures/key_up_shortcut_actions.json new file mode 100644 index 000000000..e916844d7 --- /dev/null +++ b/flutter/test/fixtures/key_up_shortcut_actions.json @@ -0,0 +1,8 @@ +[ + "close_tab", + "switch_tab_next", + "switch_tab_prev", + "restart_remote", + "switch_sides", + "toggle_chat" +] diff --git a/flutter/test/fixtures/shortcut_key_usb_hid.json b/flutter/test/fixtures/shortcut_key_usb_hid.json new file mode 100644 index 000000000..f85d99fe9 --- /dev/null +++ b/flutter/test/fixtures/shortcut_key_usb_hid.json @@ -0,0 +1,65 @@ +[ + ["a", 4], + ["b", 5], + ["c", 6], + ["d", 7], + ["e", 8], + ["f", 9], + ["g", 10], + ["h", 11], + ["i", 12], + ["j", 13], + ["k", 14], + ["l", 15], + ["m", 16], + ["n", 17], + ["o", 18], + ["p", 19], + ["q", 20], + ["r", 21], + ["s", 22], + ["t", 23], + ["u", 24], + ["v", 25], + ["w", 26], + ["x", 27], + ["y", 28], + ["z", 29], + ["digit1", 30], + ["digit2", 31], + ["digit3", 32], + ["digit4", 33], + ["digit5", 34], + ["digit6", 35], + ["digit7", 36], + ["digit8", 37], + ["digit9", 38], + ["digit0", 39], + ["f1", 58], + ["f2", 59], + ["f3", 60], + ["f4", 61], + ["f5", 62], + ["f6", 63], + ["f7", 64], + ["f8", 65], + ["f9", 66], + ["f10", 67], + ["f11", 68], + ["f12", 69], + ["delete", 76], + ["backspace", 42], + ["tab", 43], + ["space", 44], + ["enter", 40], + ["enter", 88], + ["arrow_left", 80], + ["arrow_right", 79], + ["arrow_up", 82], + ["arrow_down", 81], + ["home", 74], + ["end", 77], + ["page_up", 75], + ["page_down", 78], + ["insert", 73] +] diff --git a/flutter/test/fixtures/supported_shortcut_keys.json b/flutter/test/fixtures/supported_shortcut_keys.json new file mode 100644 index 000000000..69fb67f44 --- /dev/null +++ b/flutter/test/fixtures/supported_shortcut_keys.json @@ -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" +] diff --git a/flutter/test/keyboard_shortcuts_test.dart b/flutter/test/keyboard_shortcuts_test.dart new file mode 100644 index 000000000..58b570e61 --- /dev/null +++ b/flutter/test/keyboard_shortcuts_test.dart @@ -0,0 +1,507 @@ +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'; +import 'package:flutter_hbb/models/shortcut_model.dart'; + +ShortcutPlatformCapabilities capabilities({ + bool includeFullscreenShortcut = true, + bool includeScreenshotShortcut = true, + bool includeRelativeMouseModeShortcut = 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 includeVoiceCallShortcut = true, + Set excludedActions = const {}, +}) { + return ShortcutPlatformCapabilities( + includeFullscreenShortcut: includeFullscreenShortcut, + includeScreenshotShortcut: includeScreenshotShortcut, + includeRelativeMouseModeShortcut: includeRelativeMouseModeShortcut, + includeTabShortcuts: includeTabShortcuts, + includeToolbarShortcut: includeToolbarShortcut, + includeCloseTabShortcut: includeCloseTabShortcut, + includeSwitchSidesShortcut: includeSwitchSidesShortcut, + includeRecordingShortcut: includeRecordingShortcut, + includeResetCanvasShortcut: includeResetCanvasShortcut, + includePinToolbarShortcut: includePinToolbarShortcut, + includeViewModeShortcut: includeViewModeShortcut, + includeVoiceCallShortcut: includeVoiceCallShortcut, + excludedActions: excludedActions, + ); +} + +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; + 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('shortcutBindingMapsFrom ignores malformed bindings', () { + expect(shortcutBindingMapsFrom('not a list'), isEmpty); + + final bindings = shortcutBindingMapsFrom([ + { + 'action': kShortcutActionScreenshot, + 'mods': ['primary'], + 'key': 'p', + }, + 'bad', + 1, + { + 'action': kShortcutActionToggleMute, + 'mods': ['alt'], + 'key': 's', + }, + ]); + + expect(bindings, hasLength(2)); + expect(bindings.map((binding) => binding['action']), [ + kShortcutActionScreenshot, + kShortcutActionToggleMute, + ]); + }); + + test('shortcutBindingMapsFrom ignores malformed binding fields', () { + final valid = { + 'action': kShortcutActionScreenshot, + 'mods': ['primary', 'ctrl', 'alt', 'shift'], + 'key': 'p', + }; + final malformed = [ + for (final field in ['action', 'mods', 'key']) + {...valid}..remove(field), + for (final fields in [ + {'action': 1}, + {'action': null}, + {'key': 1}, + {'key': null}, + {'mods': null}, + {'mods': 'primary'}, + {'mods': ['primary', 1]}, + {'mods': ['primary', null]}, + {'mods': ['primary', 'unknown']}, + ]) + {...valid, ...fields}, + ]; + + for (final binding in malformed) { + expect(shortcutBindingMapsFrom([binding, valid]), [valid], + reason: 'malformed binding should be ignored: $binding'); + } + }); + + test('shortcutBindingMapsFrom preserves unbound and unknown bindings', () { + expect( + shortcutBindingMapsFrom([ + {'action': kShortcutActionScreenshot, 'mods': [], 'key': ''}, + {'action': 'future_action', 'mods': ['ctrl'], 'key': 'future_key'}, + ]), + [ + {'action': kShortcutActionScreenshot, 'mods': [], 'key': ''}, + {'action': 'future_action', 'mods': ['ctrl'], 'key': 'future_key'}, + ], + ); + }); + + test('platform filtering ignores malformed binding fields', () { + final filtered = filterDefaultBindingsForPlatform([ + {'action': 1, 'mods': ['primary'], 'key': 'p'}, + {'action': kShortcutActionScreenshot, 'mods': ['primary'], 'key': 1}, + {'action': kShortcutActionToggleMute, 'mods': ['alt'], 'key': 's'}, + { + 'action': kShortcutActionToggleFullscreen, + 'mods': ['primary'], + 'key': 'enter', + }, + ], capabilities(includeFullscreenShortcut: false)); + + expect(filtered, [ + {'action': kShortcutActionToggleMute, 'mods': ['alt'], 'key': 's'}, + ]); + }); + + test('shortcutModSetFrom ignores malformed modifiers', () { + expect(shortcutModSetFrom('not a list'), isEmpty); + expect(shortcutModSetFrom(['primary', 1, 'alt', null, 'primary']), { + 'primary', + 'alt', + }); + }); + + 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', + }, + { + 'action': kShortcutActionToggleRelativeMouseMode, + 'mods': ['primary', 'alt', 'shift'], + 'key': 'g', + }, + ]; + + final filtered = filterDefaultBindingsForPlatform( + defaults, + capabilities( + includeFullscreenShortcut: false, + includeScreenshotShortcut: false, + includeRelativeMouseModeShortcut: false, + includeTabShortcuts: false, + includeToolbarShortcut: false, + includeCloseTabShortcut: false, + includeSwitchSidesShortcut: false, + includeRecordingShortcut: false, + includeResetCanvasShortcut: false, + includePinToolbarShortcut: false, + includeViewModeShortcut: false, + includeVoiceCallShortcut: false, + ), + ); + + expect(filtered.map((binding) => binding['action']), [ + kShortcutActionSendCtrlAltDel, + kShortcutActionSwitchDisplayNext, + ]); + }); + + Set idSet(Iterable groups) => + {for (final e in allActionEntries(groups)) e.id}; + + test('filterKeyboardShortcutActionGroupsForPlatform strips desktop-only', () { + final groups = filterKeyboardShortcutActionGroupsForPlatform( + capabilities( + includeFullscreenShortcut: false, + includeScreenshotShortcut: false, + includeRelativeMouseModeShortcut: 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, + includeVoiceCallShortcut: false, + ), + ); + final ids = idSet(groups); + // Desktop-only actions are stripped. + expect(ids, isNot(contains(kShortcutActionToggleFullscreen))); + expect(ids, isNot(contains(kShortcutActionToggleRelativeMouseMode))); + 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('Wayland hides relative mouse mode but keeps screenshot', () { + final ids = idSet(filterKeyboardShortcutActionGroupsForPlatform( + capabilities(includeRelativeMouseModeShortcut: false), + )); + expect(ids, isNot(contains(kShortcutActionToggleRelativeMouseMode))); + expect(ids, contains(kShortcutActionScreenshot)); + }); + + test('filterKeyboardShortcutActionGroupsForPlatform drops empty groups', () { + // 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, + includeRelativeMouseModeShortcut: false, + includeTabShortcuts: false, + includeToolbarShortcut: false, + includeCloseTabShortcut: false, + includeSwitchSidesShortcut: false, + includeRecordingShortcut: false, + includeResetCanvasShortcut: false, + includePinToolbarShortcut: false, + includeViewModeShortcut: 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() + .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('physicalKeyName covers the supported-keys fixture', () { + // `shortcut_key_usb_hid.json` pins every key name to the USB HID usage + // it is recorded and matched from. Rust has a mirror test against the + // same file (`usb_hid_keys_match_fixture` in src/keyboard/shortcuts.rs), + // and the set of names must equal `supported_shortcut_keys.json`. + final supported = (jsonDecode( + File('test/fixtures/supported_shortcut_keys.json') + .readAsStringSync()) as List) + .cast() + .toSet(); + final pairs = (jsonDecode(File('test/fixtures/shortcut_key_usb_hid.json') + .readAsStringSync()) as List) + .cast>(); + + for (final pair in pairs) { + final name = pair[0] as String; + final usage = pair[1] as int; + expect(physicalKeyName(PhysicalKeyboardKey(0x00070000 | usage)), + equals(name), + reason: 'USB HID 0x${usage.toRadixString(16)} should be "$name"'); + } + expect(pairs.map((p) => p[0] as String).toSet(), equals(supported)); + + // Modifier-only / unsupported keys must return null. + expect(physicalKeyName(PhysicalKeyboardKey.shiftLeft), isNull); + expect(physicalKeyName(PhysicalKeyboardKey.escape), isNull); + expect(physicalKeyName(PhysicalKeyboardKey.f13), isNull); + expect(physicalKeyName(PhysicalKeyboardKey.numpad1), isNull); + }); + + test('enable seeds defaults only for a config without bindings', () { + expect(shouldSeedDefaultShortcutBindings({}), isTrue); + expect( + shouldSeedDefaultShortcutBindings( + {'enabled': false, 'pass_through': true}), + isTrue); + // The user cleared every binding: re-enabling must not bring defaults back. + expect( + shouldSeedDefaultShortcutBindings( + {'enabled': false, 'bindings': []}), + isFalse); + }); + + test('ShortcutConfig.parse reads the flags and bindings once', () { + final config = ShortcutConfig.parse(jsonEncode({ + 'enabled': true, + 'pass_through': false, + 'bindings': [ + {'action': 'screenshot', 'mods': ['primary'], 'key': 'p'}, + 'not a binding', + ], + })); + expect(config.enabled, isTrue); + expect(config.passThrough, isFalse); + expect(config.bindings, [ + {'action': 'screenshot', 'mods': ['primary'], 'key': 'p'}, + ]); + + for (final raw in ['', 'not json', '[]', '{"bindings": "x"}']) { + final broken = ShortcutConfig.parse(raw); + expect(broken.enabled, isFalse, reason: raw); + expect(broken.passThrough, isFalse, reason: raw); + expect(broken.bindings, isEmpty, reason: raw); + } + }); + + test('Wayland active config preserves stored bindings and flags', () { + const relative = { + 'action': kShortcutActionToggleRelativeMouseMode, + 'mods': ['primary'], + 'key': 'r', + }; + const mute = { + 'action': kShortcutActionToggleMute, + 'mods': ['primary'], + 'key': 'm', + }; + for (final flags in [(true, false), (false, false), (true, true)]) { + final stored = ShortcutConfig.parse(jsonEncode({ + 'enabled': flags.$1, + 'pass_through': flags.$2, + 'bindings': [relative, mute], + })); + final active = stored.forPlatform( + capabilities(includeRelativeMouseModeShortcut: false)); + expect(active.bindings, [mute]); + expect(active.enabled, flags.$1); + expect(active.passThrough, flags.$2); + expect(stored.bindings, [relative, mute]); + expect(stored.forPlatform(capabilities()).bindings, [relative, mute]); + } + }); + + test('active saved bindings follow Settings capabilities without reseeding', () { + final actions = [ + kShortcutActionSwitchDisplayAll, + kShortcutActionKeyboardModeMap, + kShortcutActionToggleShowMyCursor, + kShortcutActionToggleZoomCursor, + kShortcutActionToggleRecording, + kShortcutActionToggleVoiceCall, + kShortcutActionResetCanvas, + kShortcutActionToggleMute, + kShortcutActionSendCtrlAltDel, + ]; + final saved = ShortcutConfig.parse(jsonEncode({ + 'enabled': true, + 'bindings': [ + for (final action in actions) + {'action': action, 'mods': ['primary'], 'key': 'm'}, + ], + })); + final cap = capabilities( + includeRecordingShortcut: false, + includeVoiceCallShortcut: false, + includeResetCanvasShortcut: false, + excludedActions: { + kShortcutActionSwitchDisplayAll, + kShortcutActionKeyboardModeMap, + kShortcutActionToggleShowMyCursor, + kShortcutActionToggleZoomCursor, + }, + ); + expect(saved.forPlatform(cap).bindings.map((b) => b['action']), + [kShortcutActionToggleMute, kShortcutActionSendCtrlAltDel]); + expect(saved.bindings.map((b) => b['action']), actions); + expect(saved.forPlatform(capabilities()).bindings, saved.bindings); + }); + + test('non-US layouts record and match the physical key', () { + // AZERTY: the key labelled "A" sits where US QWERTY has Q. The native + // matcher only sees the physical position (USB HID usage), so the + // binding must name that position too. + const azertyA = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyQ, + logicalKey: LogicalKeyboardKey.keyA, + character: 'a', + timeStamp: Duration.zero, + ); + expect(shortcutKeyNameForEvent(azertyA), 'q'); + + // QWERTZ: the key labelled "Z" sits where US QWERTY has Y. + const qwertzZ = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyY, + logicalKey: LogicalKeyboardKey.keyZ, + character: 'z', + timeStamp: Duration.zero, + ); + expect(shortcutKeyNameForEvent(qwertzZ), 'y'); + }); + + test('kShortcutActionsRunOnKeyUp matches the fixture and names real actions', + () { + // Rust has a mirror test against the same file + // (`key_up_actions_match_fixture` in src/keyboard/shortcuts.rs). + final fixture = (jsonDecode( + File('test/fixtures/key_up_shortcut_actions.json') + .readAsStringSync()) as List) + .cast() + .toSet(); + expect(kShortcutActionsRunOnKeyUp, equals(fixture), + reason: 'kShortcutActionsRunOnKeyUp drifted from the fixture — update ' + 'shortcut_constants.dart, the fixture, and Rust runs_on_release ' + 'together'); + final actions = idSet(kKeyboardShortcutActionGroups); + for (final id in kShortcutActionsRunOnKeyUp) { + expect(actions, contains(id), reason: '"$id" is not a configurable action'); + } + }); + +} diff --git a/flutter/test/local_flutter_shortcuts_test.dart b/flutter/test/local_flutter_shortcuts_test.dart new file mode 100644 index 000000000..6b06520a1 --- /dev/null +++ b/flutter/test/local_flutter_shortcuts_test.dart @@ -0,0 +1,566 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_constants.dart'; +import 'package:flutter_hbb/models/local_flutter_shortcuts.dart'; + +LocalFlutterShortcutDispatcher dispatcherFor( + void Function(String) onTriggered) { + final dispatcher = LocalFlutterShortcutDispatcher(onTriggered: onTriggered); + addTearDown(dispatcher.clear); + addTearDown(LocalFlutterShortcutDispatcher.resetFiredKeys); + return dispatcher; +} + +class _ShortcutHarness { + final String? Function(KeyEvent) match; + final Future Function() releaseModifiers; + final LocalFlutterShortcutDispatcher dispatcher; + + _ShortcutHarness({ + required this.match, + required this.releaseModifiers, + required void Function(String) onTriggered, + }) : dispatcher = dispatcherFor(onTriggered); + + bool tryDispatch(KeyEvent event) => dispatcher.tryDispatch( + event, + match: () => match(event), + releaseModifiers: releaseModifiers, + ); + + void clear() => dispatcher.clear(); +} + +KeyEvent down(PhysicalKeyboardKey key, + {LogicalKeyboardKey logicalKey = LogicalKeyboardKey.keyP}) => + KeyDownEvent( + physicalKey: key, + logicalKey: logicalKey, + timeStamp: Duration.zero, + ); + +KeyEvent repeat(PhysicalKeyboardKey key) => KeyRepeatEvent( + physicalKey: key, + logicalKey: LogicalKeyboardKey.keyP, + timeStamp: Duration.zero, + ); + +KeyEvent up(PhysicalKeyboardKey key, + {LogicalKeyboardKey logicalKey = LogicalKeyboardKey.keyP}) => + KeyUpEvent( + physicalKey: key, + logicalKey: logicalKey, + timeStamp: Duration.zero, + ); + +RawKeyEvent rawP({required bool down, bool repeat = false, bool ctrl = false}) { + final data = RawKeyEventDataLinux( + keyHelper: GtkKeyHelper(), + scanCode: 33, + keyCode: 112, + unicodeScalarValues: 112, + modifiers: ctrl ? GtkKeyHelper.modifierControl : 0, + isDown: down, + ); + return down + ? RawKeyDownEvent(data: data, repeat: repeat) + : RawKeyUpEvent(data: data); +} + +void main() { + for (final action in [ + kShortcutActionToggleViewOnly, + kShortcutActionToggleShowMyCursor, + kShortcutActionToggleMute, + ...kShortcutActionsRunOnKeyUp, + kShortcutActionSendClipboardKeystrokes, + ]) { + test('forwarded W retains correct ownership across $action', () async { + final events = ['W down']; + final released = Completer(); + final dispatcher = dispatcherFor(events.add); + dispatcher.recordForwardedKey(PhysicalKeyboardKey.keyW, + down: true, release: () { + events.add('W up'); + return released.future; + }); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP), + match: () => action), isTrue); + await pumpEventQueue(); + if (kShortcutActionsRunOnKeyUp.contains(action) || + (!kIsWeb && action == kShortcutActionSendClipboardKeystrokes)) { + expect(events, ['W down']); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + await pumpEventQueue(); + } + if (action == kShortcutActionToggleMute || + (kIsWeb && action == kShortcutActionSendClipboardKeystrokes)) { + expect(events, ['W down', action]); + } else { + expect(events, ['W down', 'W up']); + } + released.complete(); + await pumpEventQueue(); + expect(events.last, action); + }); + } + + for (final action in [ + kShortcutActionToggleViewOnly, + kShortcutActionToggleChat, + if (!kIsWeb) kShortcutActionSendClipboardKeystrokes, + ]) { + test('$action cleanup skips released keys and cancels after session close', + () async { + final events = []; + final released = Completer(); + final dispatcher = dispatcherFor(events.add); + dispatcher.recordForwardedKey(PhysicalKeyboardKey.keyW, + down: true, release: () { + events.add('W up'); + return released.future; + }); + dispatcher.recordForwardedKey(PhysicalKeyboardKey.keyC, + down: true, release: () async => events.add('C up')); + dispatcher.recordForwardedKey(PhysicalKeyboardKey.keyC, + down: false, release: () async => events.add('C up')); + dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP), match: () => action); + dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)); + await pumpEventQueue(); + expect(events, ['W up']); + dispatcher.clear(); + released.complete(); + await pumpEventQueue(); + expect(events, ['W up']); + }); + } + + test('owns each overlapping shortcut key until its own release', () async { + var modifiersPressed = true; + final actions = []; + final dispatcher = _ShortcutHarness( + match: (event) => modifiersPressed + ? event.physicalKey == PhysicalKeyboardKey.keyP + ? 'screenshot' + : 'chat' + : null, + releaseModifiers: () async {}, + onTriggered: actions.add, + ); + + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyC)), isTrue); + modifiersPressed = false; + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyC)), isTrue); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isFalse); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyC)), isTrue); + await Future.value(); + expect(actions, ['screenshot', 'chat']); + }); + + test('binding changes do not forward a previously consumed key', () async { + var enabled = true; + final actions = []; + final dispatcher = _ShortcutHarness( + match: (_) => enabled ? 'screenshot' : null, + releaseModifiers: () async {}, + onTriggered: actions.add, + ); + + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isTrue); + enabled = false; + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isFalse); + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isFalse); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)), isFalse); + await Future.value(); + expect(actions, ['screenshot']); + }); + + test('unmatched key stays forwarded when modifiers change during repeat', + () async { + var modifiersPressed = false; + final actions = []; + final dispatcher = _ShortcutHarness( + match: (_) => modifiersPressed ? 'screenshot' : null, + releaseModifiers: () async {}, + onTriggered: actions.add, + ); + + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isFalse); + modifiersPressed = true; + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isFalse); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)), isFalse); + await Future.value(); + expect(actions, isEmpty); + }); + + test('auto-repeat cannot trigger an action again', () async { + final actions = []; + final dispatcher = _ShortcutHarness( + match: (_) => 'screenshot', + releaseModifiers: () async {}, + onTriggered: actions.add, + ); + + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isTrue); + await Future.value(); + expect(actions, ['screenshot', 'screenshot']); + }); + + test('releases remote modifiers before running the action', () async { + final released = Completer(); + final actions = []; + var releases = 0; + final dispatcher = _ShortcutHarness( + match: (_) => 'screenshot', + releaseModifiers: () { + releases++; + return released.future; + }, + onTriggered: actions.add, + ); + + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyP)), isTrue); + expect(releases, 1); + expect(actions, isEmpty); + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isTrue); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + expect(releases, 1); + released.complete(); + await Future.value(); + expect(actions, ['screenshot']); + }); + + test('native clipboard prompt can be repeated immediately after cancelling', + () async { + var dialogOpen = false; + var prompts = 0; + final dispatcher = _ShortcutHarness( + match: (_) => kShortcutActionSendClipboardKeystrokes, + releaseModifiers: () async {}, + onTriggered: (_) { + dialogOpen = true; + prompts++; + }, + ); + bool route(KeyEvent event) => !dialogOpen && dispatcher.tryDispatch(event); + + for (var attempt = 1; attempt <= 2; attempt++) { + expect(route(down(PhysicalKeyboardKey.keyV)), isTrue); + await Future.value(); + expect(dialogOpen, isFalse, + reason: 'the local dialog must not consume the trigger release'); + expect(route(repeat(PhysicalKeyboardKey.keyV)), isTrue); + expect(route(up(PhysicalKeyboardKey.keyV)), isTrue); + await Future.value(); + expect(dialogOpen, isTrue); + expect(prompts, attempt); + dialogOpen = false; + } + }, skip: kIsWeb); + + test('native clipboard waits for outstanding remote modifier releases', + () async { + final released = Completer(); + final actions = []; + final dispatcher = _ShortcutHarness( + match: (_) => kShortcutActionSendClipboardKeystrokes, + releaseModifiers: () => released.future, + onTriggered: actions.add, + ); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyV)), isTrue); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyV)), isTrue); + await Future.value(); + expect(actions, isEmpty, + reason: 'clipboard input must follow the remote modifier releases'); + released.complete(); + await Future.value(); + expect(actions, [kShortcutActionSendClipboardKeystrokes]); + }, skip: kIsWeb); + + test('Web clipboard still runs on key down for browser activation', () async { + final actions = []; + final dispatcher = _ShortcutHarness( + match: (_) => kShortcutActionSendClipboardKeystrokes, + releaseModifiers: () async {}, + onTriggered: actions.add, + ); + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyV)), isTrue); + await Future.value(); + expect(actions, [kShortcutActionSendClipboardKeystrokes]); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyV)), isTrue); + expect(actions, [kShortcutActionSendClipboardKeystrokes]); + }, skip: !kIsWeb); + + test('closing a clipboard owner preserves another session release', () async { + final closingRelease = Completer(); + final nextRelease = Completer(); + final actions = []; + final closing = _ShortcutHarness( + match: (_) => kShortcutActionSendClipboardKeystrokes, + releaseModifiers: () => closingRelease.future, + onTriggered: (_) => actions.add('closing'), + ); + final next = _ShortcutHarness( + match: (_) => kShortcutActionSendClipboardKeystrokes, + releaseModifiers: () => nextRelease.future, + onTriggered: (_) => actions.add('next'), + ); + + expect(closing.tryDispatch(down(PhysicalKeyboardKey.keyV)), isTrue); + expect(next.tryDispatch(down(PhysicalKeyboardKey.keyC)), isTrue); + closing.clear(); + expect(next.tryDispatch(up(PhysicalKeyboardKey.keyC)), isTrue); + closingRelease.complete(); + await Future.value(); + expect(actions, isEmpty, + reason: 'the live session still awaits its own modifier release'); + expect(next.tryDispatch(up(PhysicalKeyboardKey.keyV)), isTrue); + nextRelease.complete(); + await Future.value(); + expect(actions, ['next']); + }, skip: kIsWeb); + + test('closing the session drops its pending action but keeps the fired key', + () async { + // Close tab is itself a shortcut: the session that matched the key is + // gone before the key is released, and the next session gets the rest. + final released = Completer(); + final actions = []; + var enabled = true; + final closing = _ShortcutHarness( + match: (_) => enabled ? 'close_tab' : null, + releaseModifiers: () => released.future, + onTriggered: actions.add, + ); + final next = _ShortcutHarness( + match: (_) => enabled ? 'close_tab' : null, + releaseModifiers: () async {}, + onTriggered: actions.add, + ); + + expect(closing.tryDispatch(down(PhysicalKeyboardKey.keyP)), isTrue); + closing.clear(); + released.complete(); + await Future.value(); + expect(actions, isEmpty, reason: 'a closed session runs nothing'); + + expect(next.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isTrue); + expect(next.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + enabled = false; + expect(next.tryDispatch(down(PhysicalKeyboardKey.keyP)), isFalse); + expect(next.tryDispatch(up(PhysicalKeyboardKey.keyP)), isFalse); + await Future.value(); + expect(actions, isEmpty); + }); + + test('focus-changing actions run when the key is released', () async { + // Close tab moves focus before the key is released; the release may then + // land in a session whose keys go through the other matcher. + var releases = 0; + final actions = []; + final dispatcher = _ShortcutHarness( + match: (_) => kShortcutActionCloseTab, + releaseModifiers: () async => releases++, + onTriggered: actions.add, + ); + + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyW)), isTrue); + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyW)), isTrue); + await Future.value(); + expect(releases, 1, reason: 'remote modifiers are released on the press'); + expect(actions, isEmpty, reason: 'the action waits for the release'); + + final other = dispatcherFor(actions.add); + expect(other.tryDispatch(up(PhysicalKeyboardKey.keyW)), isTrue); + await Future.value(); + expect(actions, [kShortcutActionCloseTab]); + expect(other.tryDispatch(up(PhysicalKeyboardKey.keyW)), isFalse); + }); + + test('closing the session drops its action pending on key release', + () async { + final actions = []; + final dispatcher = _ShortcutHarness( + match: (_) => kShortcutActionSwitchTabNext, + releaseModifiers: () async {}, + onTriggered: actions.add, + ); + + expect(dispatcher.tryDispatch(down(PhysicalKeyboardKey.keyW)), isTrue); + dispatcher.clear(); + expect(dispatcher.tryDispatch(up(PhysicalKeyboardKey.keyW)), isTrue, + reason: 'the key stays owned'); + await Future.value(); + expect(actions, isEmpty); + }); + + test('raw legacy shortcuts own repeats and release after modifiers change', + () async { + addTearDown(RawKeyboard.instance.clearKeysPressed); + final actions = []; + final dispatcher = dispatcherFor(actions.add); + bool dispatch(RawKeyEvent event) { + RawKeyboard.instance.handleRawKeyEvent(event); + return dispatcher.tryDispatchRaw( + event, + match: () => event.isControlPressed ? 'screenshot' : null, + releaseModifiers: () async {}, + ); + } + + expect(dispatch(rawP(down: true, ctrl: true)), isTrue); + expect(dispatch(rawP(down: true, repeat: true)), isTrue); + expect(dispatch(rawP(down: false)), isTrue); + expect(dispatch(rawP(down: true)), isFalse); + expect(dispatch(rawP(down: true, repeat: true, ctrl: true)), isFalse); + expect(dispatch(rawP(down: false, ctrl: true)), isFalse); + await Future.value(); + expect(actions, ['screenshot']); + }); + + test('shortcut key ownership follows focus to another session', () { + final actions = []; + final first = dispatcherFor(actions.add); + final second = dispatcherFor(actions.add); + + expect( + first.tryDispatch(down(PhysicalKeyboardKey.keyP), + match: () => 'next_tab'), + isTrue, + ); + expect(second.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isTrue); + expect(second.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + expect( + first.tryDispatch(down(PhysicalKeyboardKey.keyP), + match: () => 'next_tab'), + isTrue, + ); + expect(actions, ['next_tab', 'next_tab']); + }); + + test('closing another session keeps every fired key owned', () { + final first = dispatcherFor((_) {}); + final second = dispatcherFor((_) {}); + first.tryDispatch(down(PhysicalKeyboardKey.keyP), + match: () => 'screenshot'); + second.tryDispatch(down(PhysicalKeyboardKey.keyC), match: () => 'chat'); + + second.clear(); + expect(second.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isTrue); + expect(first.tryDispatch(repeat(PhysicalKeyboardKey.keyC)), isTrue); + expect(second.tryDispatch(up(PhysicalKeyboardKey.keyP)), isTrue); + expect(first.tryDispatch(up(PhysicalKeyboardKey.keyC)), isTrue); + expect(first.tryDispatch(up(PhysicalKeyboardKey.keyP)), isFalse); + expect(first.tryDispatch(down(PhysicalKeyboardKey.keyC)), isFalse); + }); + + test('restores released modifiers before the next ordinary map key', () { + final dispatcher = dispatcherFor((_) {}); + dispatcher.recordReleasedModifiers([ + PhysicalKeyboardKey.controlLeft, + PhysicalKeyboardKey.altRight, + ]); + final pressed = {PhysicalKeyboardKey.controlLeft, PhysicalKeyboardKey.keyC}; + + expect( + dispatcher.takeModifiersToRestore( + down(PhysicalKeyboardKey.keyC), pressed), + [PhysicalKeyboardKey.controlLeft], + ); + expect( + dispatcher.takeModifiersToRestore( + repeat(PhysicalKeyboardKey.keyC), pressed), + isEmpty, + ); + }); + + test('modifier events do not replay previously released modifiers', () { + final dispatcher = dispatcherFor((_) {}); + dispatcher.recordReleasedModifiers([PhysicalKeyboardKey.controlLeft]); + expect( + dispatcher.takeModifiersToRestore(down(PhysicalKeyboardKey.shiftLeft), + {PhysicalKeyboardKey.controlLeft, PhysicalKeyboardKey.shiftLeft}), + isEmpty, + ); + expect( + dispatcher.takeModifiersToRestore( + up(PhysicalKeyboardKey.controlLeft), {PhysicalKeyboardKey.shiftLeft}), + isEmpty, + ); + expect( + dispatcher.takeModifiersToRestore(down(PhysicalKeyboardKey.keyC), + {PhysicalKeyboardKey.shiftLeft, PhysicalKeyboardKey.keyC}), + isEmpty, + ); + }); + + test('session closure drops pending modifier replay', () { + final dispatcher = dispatcherFor((_) {}); + dispatcher.recordReleasedModifiers([PhysicalKeyboardKey.controlLeft]); + dispatcher.clear(); + expect( + dispatcher.takeModifiersToRestore(down(PhysicalKeyboardKey.keyC), + {PhysicalKeyboardKey.controlLeft, PhysicalKeyboardKey.keyC}), + isEmpty, + ); + }); + + for (final releaseControl in [false, true]) { + test( + 'view-only resume restores only still-held modifiers ' + '(releaseControl=$releaseControl)', () { + final keyboard = HardwareKeyboard(); + var viewOnly = true; + final dispatcher = dispatcherFor((_) => viewOnly = false); + final controlDown = down(PhysicalKeyboardKey.controlLeft, + logicalKey: LogicalKeyboardKey.controlLeft); + keyboard.handleKeyEvent(controlDown); + expect(dispatcher.tryDispatch(controlDown, viewOnly: true), isFalse); + + final shortcutDown = down(PhysicalKeyboardKey.keyP); + keyboard.handleKeyEvent(shortcutDown); + expect( + dispatcher.tryDispatch(shortcutDown, + viewOnly: true, match: () => 'toggle_view_only'), + isTrue, + ); + expect(viewOnly, isFalse); + expect(dispatcher.tryDispatch(repeat(PhysicalKeyboardKey.keyP)), isTrue); + final shortcutUp = up(PhysicalKeyboardKey.keyP); + keyboard.handleKeyEvent(shortcutUp); + expect(dispatcher.tryDispatch(shortcutUp), isTrue); + if (releaseControl) { + final controlUp = up(PhysicalKeyboardKey.controlLeft, + logicalKey: LogicalKeyboardKey.controlLeft); + keyboard.handleKeyEvent(controlUp); + } + + final copyDown = + down(PhysicalKeyboardKey.keyC, logicalKey: LogicalKeyboardKey.keyC); + keyboard.handleKeyEvent(copyDown); + expect(dispatcher.tryDispatch(copyDown), isFalse); + expect( + dispatcher.resumeModifiersAfterViewOnly( + copyDown.physicalKey, keyboard.physicalKeysPressed), + isTrue, + ); + expect( + dispatcher.takeModifiersToRestore( + copyDown, keyboard.physicalKeysPressed), + releaseControl ? isEmpty : [PhysicalKeyboardKey.controlLeft], + ); + expect(keyboard.isControlPressed, !releaseControl); + }); + } +} diff --git a/flutter/test/shortcut_model_test.dart b/flutter/test/shortcut_model_test.dart new file mode 100644 index 000000000..f2d95c4a0 --- /dev/null +++ b/flutter/test/shortcut_model_test.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dash_chat_2/dash_chat_2.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/common.dart' show SessionID; +import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_constants.dart'; +import 'package:flutter_hbb/generated_bridge.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/chat_model.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/local_flutter_shortcuts.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:uuid/uuid.dart'; + +class _ChatBinding extends Fake implements RustdeskImpl { + final sent = <(SessionID, String)>[]; + final cmSent = <(int, String)>[]; + + @override + String translate( + {required String name, required String locale, dynamic hint}) => + name; + + @override + Future sessionSendChat( + {required SessionID sessionId, + required String text, + dynamic hint}) async { + sent.add((sessionId, text)); + } + + @override + Future cmSendChat( + {required int connId, required String msg, dynamic hint}) async { + cmSent.add((connId, msg)); + } +} + +class _Chat extends ChatModel { + _Chat(FFI session) : super(WeakReference(session)); + + final opened = []; + + @override + void toggleChatOverlay( + {Offset? chatInitPos, bool requestSoftKeyboard = true}) { + opened.add(currentKey); + } +} + +class _LegacyBinding extends _ChatBinding { + final keys = <(String, bool, bool)>[]; + + @override + bool sessionGetToggleOptionSync( + {required SessionID sessionId, required String arg, dynamic hint}) => + false; + + @override + String mainGetInputSource({dynamic hint}) => 'Input source 2'; + @override + bool mainCurrentIsWayland({dynamic hint}) => false; + @override + String mainGetLocalOption({required String key, dynamic hint}) => + '{"enabled":true,"bindings":[{"action":"toggle_mute",' + '"key":"s","mods":["alt"]}]}'; + + @override + Future sessionInputKey( + {required SessionID sessionId, + required String name, + required bool down, + required bool press, + required bool alt, + required bool ctrl, + required bool shift, + required bool command, + dynamic hint}) async { + expect([press, ctrl, shift, command], [false, false, false, false]); + keys.add((name, down, alt)); + } + + @override + Future sessionSendMouse( + {required SessionID sessionId, required String msg, dynamic hint}) async { + expect(jsonDecode(msg)['relative_mouse_mode'], '0'); + } +} + +Future _rawKey(InputModel input, + {required int scan, + required int key, + bool down = true, + bool alt = true}) async { + final data = RawKeyEventDataLinux( + keyHelper: GtkKeyHelper(), + scanCode: scan, + keyCode: key, + unicodeScalarValues: key < 256 ? key : 0, + modifiers: alt ? GtkKeyHelper.modifierMod1 : 0, + isDown: down); + final event = down ? RawKeyDownEvent(data: data) : RawKeyUpEvent(data: data); + RawKeyboard.instance.handleRawKeyEvent(event); + input.handleRawKeyEvent(event); +} + +class _Session extends Fake implements FFI { + @override + bool closed = false; + @override + final String id = 'test-peer'; + @override + final SessionID sessionId = const Uuid().v4obj(); + @override + late final FfiModel ffiModel = FfiModel(WeakReference(this)); + @override + late final _Chat chatModel = _Chat(this); + @override + late ShortcutModel shortcutModel; + @override + ConnType get connType => ConnType.defaultConn; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _Session session; + late ShortcutModel model; + + setUp(() { + session = _Session(); + model = ShortcutModel(WeakReference(session)); + session.shortcutModel = model; + }); + + tearDown(() => model.clear()); + + for (final peer in [ + kPeerPlatformWindows, + kPeerPlatformLinux, + kPeerPlatformMacOS + ]) { + for (final alt in [(64, 0xffe9, 'VK_MENU'), (108, 0xffea, 'RAlt')]) { + test('Legacy $peer ${alt.$3} shortcut releases modifiers before dispatch', + () async { + final binding = _LegacyBinding(); + platformFFI.setBindingForTesting(binding); + session.ffiModel.pi.platform = peer; + final input = InputModel(WeakReference(session)) + ..keyboardMode = kKeyLegacyMode; + addTearDown(() { + session.closed = true; + input.resetModifiers(); + input.disposeRelativeMouseMode(); + RawKeyboard.instance.clearKeysPressed(); + LocalFlutterShortcutDispatcher.resetFiredKeys(); + session.ffiModel.dispose(); + }); + final firedAt = []; + model.register( + kShortcutActionToggleMute, () => firedAt.add(binding.keys.length)); + await _rawKey(input, scan: alt.$1, key: alt.$2); + await _rawKey(input, scan: 39, key: 0x73); + await pumpEventQueue(); + + expect(binding.keys, [ + (alt.$3, true, true), + if (peer == kPeerPlatformWindows) ('VK_CONTROL', true, true), + // GTK reports Alt without a side on the letter event. + ('VK_MENU', false, false), + ('RAlt', false, false), + if (peer == kPeerPlatformWindows) ('VK_CONTROL', false, false), + ]); + expect(firedAt, [binding.keys.length]); + await _rawKey(input, scan: 39, key: 0x73, down: false); + await _rawKey(input, scan: 38, key: 0x61); + expect(binding.keys.last, ('VK_A', true, true)); + await _rawKey(input, scan: 38, key: 0x61, down: false); + await _rawKey(input, + scan: alt.$1, key: alt.$2, down: false, alt: false); + await _rawKey(input, scan: 38, key: 0x61, alt: false); + expect(binding.keys.last, ('VK_A', true, false)); + await _rawKey(input, scan: 38, key: 0x61, down: false, alt: false); + expect(binding.keys.where((key) => key.$1 == 'VK_S'), isEmpty); + }); + } + } + + test('fresh Chat shortcut sends through its outgoing session', () async { + final binding = _ChatBinding(); + platformFFI.setBindingForTesting(binding); + final chat = session.chatModel; + addTearDown(() { + chat.dispose(); + chat.inputNode.dispose(); + session.ffiModel.dispose(); + }); + expect(chat.currentKey.isOut, isFalse); + expect(chat.messages, isEmpty); + registerSessionShortcutActions(session); + + model.onTriggered(kShortcutActionToggleChat); + await pumpEventQueue(); + chat.send(ChatMessage( + text: 'first message', user: chat.me, createdAt: DateTime.now())); + await pumpEventQueue(); + + expect(binding.sent, [(session.sessionId, 'first message')]); + expect(binding.cmSent, isEmpty); + expect(chat.opened, [MessageKey(session.id, ChatModel.clientModeID)]); + expect( + chat.messages.keys, [MessageKey(session.id, ChatModel.clientModeID)]); + }); + + test('a removed menu action cannot run its cached callback', () async { + var calls = 0; + model.register('mute', () => calls++); + model.registerRefresher(['mute'], () => model.unregister('mute')); + + model.onTriggered('mute'); + await pumpEventQueue(); + + expect(calls, 0); + }); + + test('an action becomes available without reopening its menu', () async { + var calls = 0; + model.registerRefresher(['screenshot'], () { + model.register('screenshot', () => calls++); + }); + + model.onTriggered('screenshot'); + await pumpEventQueue(); + + expect(calls, 1); + }); + + test('refresh finishes before invoking the current callback', () async { + final ready = Completer(); + final calls = []; + model.register('cursor', () => calls.add('stale')); + model.registerRefresher(['cursor'], () async { + await ready.future; + model.register('cursor', () => calls.add('current')); + }); + + model.onTriggered('cursor'); + expect(calls, isEmpty); + ready.complete(); + await pumpEventQueue(); + + expect(calls, ['current']); + }); + + test('closing during a refresh prevents late registration and dispatch', + () async { + final ready = Completer(); + var calls = 0; + model.registerRefresher(['mute'], () async { + await ready.future; + model.register('mute', () => calls++); + }); + + model.onTriggered('mute'); + session.closed = true; + model.clear(); + ready.complete(); + await pumpEventQueue(); + model.onTriggered('mute'); + await pumpEventQueue(); + + expect(calls, 0); + }); +} diff --git a/src/flutter.rs b/src/flutter.rs index f83f18beb..4bde2ee37 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1671,8 +1671,10 @@ pub fn get_cur_peer_id() -> String { } pub fn set_cur_session_id(session_id: SessionID) { - if get_cur_session_id() != session_id { - *CUR_SESSION_ID.write().unwrap() = session_id; + let mut current = CUR_SESSION_ID.write().unwrap(); + if *current != session_id { + crate::keyboard::shortcuts::transfer_released_modifiers(¤t, &session_id); + *current = session_id; } } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 41968c185..deba7f671 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -300,6 +300,7 @@ pub fn will_session_close_close_session(session_id: SessionID) -> SyncReturn 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) { @@ -2356,6 +2363,17 @@ pub fn main_init_input_source() -> SyncReturn<()> { SyncReturn(()) } +pub fn main_reload_keyboard_shortcuts() -> SyncReturn<()> { + crate::keyboard::shortcuts::reload_from_config(); + SyncReturn(()) +} + +pub fn main_get_default_keyboard_shortcuts() -> SyncReturn { + let bindings = crate::keyboard::shortcuts::default_bindings(); + let json = serde_json::to_string(&bindings).unwrap_or_default(); + SyncReturn(json) +} + pub fn main_is_installed_lower_version() -> SyncReturn { SyncReturn(is_installed_lower_version()) } diff --git a/src/keyboard.rs b/src/keyboard.rs index e7bdce9b5..ca813166b 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState}; #[cfg(not(any(target_os = "android", target_os = "ios")))] use hbb_common::log; use base::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,38 @@ pub mod client { } pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option) { + // Shortcut intercept — must come before any wire encoding. + // A press that fires a shortcut is consumed, and so are its auto repeats + // and its release; every other event passes 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, + keyboard_mode, + || get_peer_platform().to_lowercase(), + send_key_event, + ) { + return; + } + let keyboard_mode = get_keyboard_mode_enum(keyboard_mode); if is_long_press(&event) { return; @@ -334,7 +369,26 @@ pub mod client { event: &Event, lock_modes: Option, session: &Session, + session_id: SessionID, ) { + // Shortcut intercept — see the long comment in `process_event` above + // for the consume / 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, + keyboard_mode, + || session.peer_platform().to_lowercase(), + |key_event| session.send_key_event(key_event), + ) { + return; + } + #[cfg(not(feature = "flutter"))] + let _ = session_id; + let keyboard_mode = get_keyboard_mode_enum(keyboard_mode); if is_long_press(&event) { return; @@ -430,9 +484,9 @@ pub mod client { send_key_event(&event_lock_screen()); } - pub fn event_ctrl_alt_del() -> KeyEvent { + pub fn event_ctrl_alt_del(peer_platform: &str) -> KeyEvent { let mut key_event = KeyEvent::new(); - if get_peer_platform() == "Windows" { + if peer_platform == "Windows" { key_event.set_control_key(ControlKey::CtrlAltDel); key_event.down = true; } else { @@ -447,7 +501,7 @@ pub mod client { #[inline] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn ctrl_alt_del() { - send_key_event(&event_ctrl_alt_del()); + send_key_event(&event_ctrl_alt_del(&get_peer_platform())); } } @@ -736,6 +790,10 @@ fn take_remote_keys() -> HashMap { fn release_remote_keys_for_events(keyboard_mode: &str, to_release: HashMap) { for (key, mut event) in to_release.into_iter() { + #[cfg(feature = "flutter")] + if get_peer_platform() == "Windows" && shortcuts::release_modifier_on_leave(key) { + continue; + } event.event_type = EventType::KeyRelease(key); client::process_event(keyboard_mode, &event, None); // If Alt or AltGr is pressed, we need to send another key stoke to release it. diff --git a/src/keyboard/shortcuts.rs b/src/keyboard/shortcuts.rs new file mode 100644 index 000000000..e015e6eca --- /dev/null +++ b/src/keyboard/shortcuts.rs @@ -0,0 +1,1614 @@ +//! 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> = 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 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, + 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, +} + +pub fn default_bindings() -> Vec { + 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 { + // iOS shares Apple's keyboard semantics with macOS — recording dialog + // already treats iOS as `_isMac`, so the matcher must too. + 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 { + 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); + #[cfg(target_os = "linux")] + let is_wayland = crate::platform::linux::current_is_wayland(); + #[cfg(not(target_os = "linux"))] + let is_wayland = false; + reload_from_raw(&raw, std::env::consts::OS, is_wayland); +} + +fn reload_from_raw(raw: &str, platform: &str, is_wayland: bool) { + let mut parsed: Bindings = 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 the controller capabilities in ShortcutModel, preserving storage + // for a later return to another platform or display server. + parsed.bindings.retain(|b| action_available_on_platform(&b.action, platform, is_wayland)); + 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); + } + } +} + +fn action_available_on_platform(action: &str, platform: &str, is_wayland: bool) -> bool { + let mobile = matches!(platform, "android" | "ios"); + match action { + action_id::TOGGLE_RELATIVE_MOUSE_MODE => !mobile && !is_wayland, + action_id::TOGGLE_FULLSCREEN | action_id::SCREENSHOT | action_id::SWITCH_TAB_NEXT + | action_id::SWITCH_TAB_PREV | action_id::CLOSE_TAB | action_id::SWITCH_SIDES + | action_id::TOGGLE_TOOLBAR | action_id::PIN_TOOLBAR | action_id::SWITCH_DISPLAY_ALL + | action_id::TOGGLE_ENABLE_FILE_COPY_PASTE | action_id::KEYBOARD_MODE_MAP + | action_id::KEYBOARD_MODE_TRANSLATE | action_id::KEYBOARD_MODE_LEGACY + | action_id::TOGGLE_SHOW_MY_CURSOR | action_id::TOGGLE_ZOOM_CURSOR => !mobile, + action_id::RESET_CANVAS => mobile, + action_id::TOGGLE_RECORDING | action_id::TOGGLE_VOICE_CALL => platform != "ios", + _ => true, + } +} + +/// Snapshot of the currently cached bindings. Cheap (one atomic increment) — +/// safe to call on every keystroke. +pub fn current() -> Arc { + 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. +/// +/// A bound chord belongs to RustDesk: it is consumed even when the action is +/// unavailable in the current session (e.g. screenshot on a peer that does not +/// support it), and the Flutter side then does nothing. It is never forwarded +/// to the remote, so what a chord does depends on the binding alone, not on +/// session state the user cannot see. +pub fn match_event(event: &rdev::Event) -> Option { + 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); + let action = match_normalized(&key_name, &mods, &bindings)?; + // AltGraph is text input, not the ordinary Alt recorded in a shortcut. + // Keep the shared modifier snapshot unchanged for remote key encoding. + #[cfg(target_os = "windows")] + if unsafe { super::IS_0X021D_DOWN } + && super::MODIFIERS_STATE.lock().unwrap().get(&rdev::Key::AltGr) == Some(&true) + { + return None; + } + #[cfg(target_os = "linux")] + if super::MODIFIERS_STATE.lock().unwrap().get(&rdev::Key::AltGr) == Some(&true) { + match crate::platform::linux::right_alt_is_level3_shift() { + Ok(true) => return None, + Ok(false) => {} + Err(err) => { + hbb_common::throttled_log!(std::time::Duration::from_secs(5), warn, + "Cannot identify AltGraph for keyboard shortcuts: {}", err); + return None; + } + } + } + Some(action.to_owned()) +} + +#[cfg(feature = "flutter")] +lazy_static::lazy_static! { + /// Physical keys whose press fired a shortcut and whose release has not + /// arrived yet. Every event of such a key belongs to RustDesk until its + /// release, whatever the modifiers do meanwhile: auto repeats and the + /// release are consumed and never reach the remote. + /// + /// Ownership is physical, so it is shared by every session: a shortcut + /// can switch focus or close its own session before the key is released, + /// and the session that receives the release is not the one that fired. + /// It is never dropped with a session. A key whose release is missed + /// heals itself: its next press is consumed as a repeat and that release + /// removes it. + static ref FIRED_KEYS: std::sync::Mutex> = + Default::default(); + /// Actions waiting for the release of the key that fired them, with the + /// session that fired them. See `runs_on_release`. + static ref RELEASE_ACTIONS: std::sync::Mutex< + std::collections::HashMap, + > = Default::default(); + static ref RELEASED_MODIFIERS: std::sync::Mutex< + std::collections::HashMap>, + > = Default::default(); +} + +/// Actions that move keyboard focus to another session or a dialog. They run when the +/// key that fired them is released, not when it is pressed, so the matcher +/// that consumed the press also sees its repeats and its release. The next +/// session may route its keys through the other matcher (Flutter's legacy +/// path on Linux), which never saw the press. Mirrors +/// `kShortcutActionsRunOnKeyUp` in `shortcut_constants.dart`; both are +/// checked against `flutter/test/fixtures/key_up_shortcut_actions.json`. +const RELEASE_ACTION_IDS: &[&str] = &[ + action_id::CLOSE_TAB, + action_id::SWITCH_TAB_NEXT, + action_id::SWITCH_TAB_PREV, + action_id::RESTART_REMOTE, + action_id::SWITCH_SIDES, + action_id::TOGGLE_CHAT, +]; + +pub fn runs_on_release(action_id: &str) -> bool { + // A clipboard consent dialog can consume the trigger release. Web keeps + // this action on key-down for browser clipboard user activation. + action_id == action_id::SEND_CLIPBOARD_KEYSTROKES || RELEASE_ACTION_IDS.contains(&action_id) +} + +/// Forget the modifiers a session released on its remote and the actions it +/// still owes on key release. Fired keys are physical and stay owned until +/// their release, whichever session gets it. +#[cfg(feature = "flutter")] +pub fn clear_session_state(session_id: &hbb_common::SessionID) { + RELEASE_ACTIONS.lock().unwrap().retain(|_, (sid, _)| sid != session_id); + let held = RELEASED_MODIFIERS.lock().unwrap().remove(session_id); + if let Some(held) = held { + { + let mut state = super::MODIFIERS_STATE.lock().unwrap(); + for key in held.keys() { + state.insert(*key, false); + } + } + let mut to_release = super::TO_RELEASE.lock().unwrap(); + for key in held.keys() { + to_release.remove(key); + } + } +} + +/// Clear local state on focus loss when the shortcut already released the remote key. +#[cfg(feature = "flutter")] +pub(super) fn release_modifier_on_leave(key: rdev::Key) -> bool { + let sid = crate::flutter::get_cur_session_id(); + let mut released = RELEASED_MODIFIERS.lock().unwrap(); + let Some(held) = released.get_mut(&sid) else { return false }; + if held.remove(&key).is_none() { + return false; + } + if held.is_empty() { + released.remove(&sid); + } + drop(released); + super::MODIFIERS_STATE.lock().unwrap().insert(key, false); + true +} + +#[cfg(feature = "flutter")] +pub fn enter_view_only(session_id: &hbb_common::SessionID) { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if super::IS_RDEV_ENABLED.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + // The Flutter input source stops routing key events here, so the release + // of a fired key can no longer reach this matcher. + FIRED_KEYS.lock().unwrap().clear(); + RELEASE_ACTIONS.lock().unwrap().clear(); + clear_session_state(session_id); +} + +#[cfg(feature = "flutter")] +pub fn transfer_released_modifiers(from: &hbb_common::SessionID, to: &hbb_common::SessionID) { + if from == to { + return; + } + let mut released = RELEASED_MODIFIERS.lock().unwrap(); + if let Some(held) = released.remove(from) { + released.entry(*to).or_default().extend(held); + } +} + +/// 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()`. +/// +/// `peer` and `send` must belong to the same session as `session_id`, so the +/// remote key releases go to the session the chord was typed into. +#[cfg(feature = "flutter")] +pub fn try_dispatch( + session_id: Option<&hbb_common::SessionID>, + event: &rdev::Event, + keyboard_mode: &str, + peer: impl FnOnce() -> String, + send: impl Fn(&base::message_proto::KeyEvent), +) -> bool { + use rdev::EventType; + let resolved; + let sid = match session_id { + Some(id) => id, + None => { + resolved = crate::flutter::get_cur_session_id(); + &resolved + } + }; + if let EventType::KeyRelease(key) = event.event_type { + let mut released = RELEASED_MODIFIERS.lock().unwrap(); + if let Some(held) = released.get_mut(sid) { + held.remove(&key); + if held.is_empty() { + released.remove(sid); + } + } + } + { + let mut fired = FIRED_KEYS.lock().unwrap(); + match event.event_type { + EventType::KeyPress(k) if fired.contains(&k) => return true, + EventType::KeyRelease(k) if fired.remove(&k) => { + // X11 Source 1 can report auto-repeat as release/press pairs. + // Held shortcuts may fire again until the backend normalizes them. + drop(fired); + let pending = RELEASE_ACTIONS.lock().unwrap().remove(&k); + if let Some((sid, action_id)) = pending { + push_shortcut_event(&sid, &action_id); + } + return true; + } + _ => {} + } + } + // A forwarded press keeps ownership through repeats. Adding modifiers + // while it is held must not consume the key-up owed to the remote. + let action_id = match event.event_type { + EventType::KeyPress(key) if super::TO_RELEASE.lock().unwrap().contains_key(&key) => None, + _ => match_event(event), + }; + let Some(action_id) = action_id else { + restore_remote_modifiers(sid, event, keyboard_mode, peer, &send); + return false; + }; + let peer = peer(); + if runs_on_release(&action_id) + || matches!(action_id.as_str(), + action_id::TOGGLE_VIEW_ONLY | action_id::TOGGLE_SHOW_MY_CURSOR + | action_id::INSERT_LOCK | action_id::SEND_CTRL_ALT_DEL + | action_id::KEYBOARD_MODE_MAP | action_id::KEYBOARD_MODE_TRANSLATE + | action_id::KEYBOARD_MODE_LEGACY) + { + release_remote_non_modifiers(keyboard_mode, &peer, &send); + } + release_remote_keys(sid, keyboard_mode, &peer, &send); + if let EventType::KeyPress(k) = event.event_type { + FIRED_KEYS.lock().unwrap().insert(k); + let mut pending = RELEASE_ACTIONS.lock().unwrap(); + if runs_on_release(&action_id) { + pending.insert(k, (*sid, action_id)); + return true; + } + pending.remove(&k); + } + push_shortcut_event(sid, &action_id); + true +} + +#[cfg(feature = "flutter")] +fn push_shortcut_event(session_id: &hbb_common::SessionID, action_id: &str) { + if let Some(session) = crate::flutter::sessions::get_session_by_session_id(session_id) { + session.ui_handler.push_event_to( + "shortcut_triggered", + &[("action", action_id)], + &[session_id], + ); + } +} + +#[cfg(feature = "flutter")] +fn restore_remote_modifiers( + session_id: &hbb_common::SessionID, + event: &rdev::Event, + keyboard_mode: &str, + peer: impl FnOnce() -> String, + send: &impl Fn(&base::message_proto::KeyEvent), +) { + use super::{event_to_key_events, get_keyboard_mode_enum, is_modifier, MODIFIERS_STATE}; + + let rdev::EventType::KeyPress(key) = event.event_type else { return }; + if is_modifier(&key) { + return; + } + let held = RELEASED_MODIFIERS.lock().unwrap().remove(session_id); + let Some(mut held) = held else { return }; + { + let state = MODIFIERS_STATE.lock().unwrap(); + held.retain(|key, _| state.get(key).copied().unwrap_or(false)); + } + if held.is_empty() { + return; + } + let peer = peer(); + let mode = get_keyboard_mode_enum(keyboard_mode); + for event in held.into_values() { + for key_event in event_to_key_events(peer.clone(), &event, mode, None) { + send(&key_event); + } + } +} + +// Actions that move focus, stop input or change its mode must release ordinary +// keys too: their physical key-up may no longer balance the original key-down. +#[cfg(feature = "flutter")] +fn release_remote_non_modifiers( + keyboard_mode: &str, + peer: &str, + send: &impl Fn(&base::message_proto::KeyEvent), +) { + use super::{event_to_key_events, get_keyboard_mode_enum, is_modifier, TO_RELEASE}; + let held: Vec<_> = TO_RELEASE.lock().unwrap().iter() + .filter(|(key, _)| !is_modifier(key)) + .map(|(key, event)| (*key, event.clone())) + .collect(); + let mode = get_keyboard_mode_enum(keyboard_mode); + for (key, mut event) in held { + event.event_type = rdev::EventType::KeyRelease(key); + for key_event in event_to_key_events(peer.to_owned(), &event, mode, None) { + send(&key_event); + } + } +} + +/// Release the remote modifiers before an action, preserving unrelated held keys. +/// +/// Unlike `keyboard::release_remote_keys` this keeps the local modifier state: +/// the user is still physically holding the modifiers, and the next key of +/// the chord (or an auto repeat) must still see them. It also sends through +/// `send` instead of the globally current session. +#[cfg(feature = "flutter")] +fn release_remote_keys( + session_id: &hbb_common::SessionID, + keyboard_mode: &str, + peer: &str, + send: &impl Fn(&base::message_proto::KeyEvent), +) { + use super::{event_to_key_events, get_keyboard_mode_enum, is_modifier, MODIFIERS_STATE, TO_RELEASE}; + use rdev::{EventType, Key}; + + let mode = get_keyboard_mode_enum(keyboard_mode); + let to_release: Vec<_> = TO_RELEASE.lock().unwrap().iter() + .filter(|(key, _)| is_modifier(key)) + .map(|(key, event)| (*key, event.clone())) + .collect(); + let held: Vec<(Key, rdev::Event)> = { + let state = MODIFIERS_STATE.lock().unwrap(); + to_release + .iter() + .filter(|(k, _)| state.get(k).copied().unwrap_or(false)) + .map(|(key, event)| (*key, event.clone())) + .collect() + }; + let swap_ctrl_cmd = crate::flutter::sessions::get_session_by_session_id(session_id) + .is_some_and(|session| session.get_toggle_option("allow_swap_key".into())); + // A consumed Alt/Meta-only chord must not activate Windows menus or Start. + let neutralize_menu = peer == "windows" + && !to_release.is_empty() + && to_release.iter().all(|(key, _)| matches!(key, Key::Alt | Key::AltGr) + || (swap_ctrl_cmd && matches!(key, Key::ControlLeft | Key::ControlRight)) + || (!swap_ctrl_cmd && matches!(key, Key::MetaLeft | Key::MetaRight))); + if neutralize_menu { + send_windows_menu_ctrl(true, swap_ctrl_cmd, send); + } + for (key, mut event) in to_release { + event.event_type = EventType::KeyRelease(key); + for key_event in event_to_key_events(peer.to_owned(), &event, mode, None) { + send(&key_event); + } + } + if neutralize_menu { + send_windows_menu_ctrl(false, swap_ctrl_cmd, send); + } + { + let mut state = MODIFIERS_STATE.lock().unwrap(); + for (key, _) in &held { + state.insert(*key, true); + } + } + RELEASED_MODIFIERS.lock().unwrap().insert( + *session_id, held.iter().cloned().collect() + ); + // Focus-loss cleanup must still reset modifiers released outside the grab. + TO_RELEASE.lock().unwrap().extend(held); +} + +#[cfg(feature = "flutter")] +fn send_windows_menu_ctrl( + down: bool, + swap_ctrl_cmd: bool, + send: &impl Fn(&base::message_proto::KeyEvent), +) { + use base::message_proto::{KeyEvent, KeyboardMode}; + const LEFT_CTRL_SCAN_CODE: u32 = 0x1d; + const LEFT_META_SCAN_CODE: u32 = 0xe05b; + let mut event = KeyEvent::new(); + // Keep Ctrl in Map mode: Legacy sync could release Alt/Meta before Ctrl. + event.mode = KeyboardMode::Map.into(); + // The session sender swaps this packet too; the mask must arrive as Ctrl. + event.set_chr(if swap_ctrl_cmd { + LEFT_META_SCAN_CODE + } else { + LEFT_CTRL_SCAN_CODE + }); + event.down = down; + send(&event); +} + +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 { + let mut 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, + }; + #[cfg(target_os = "windows")] + { + event.platform_code = rdev::win_code_from_key(k).unwrap_or_default(); + event.position_code = rdev::win_scancode_from_key(k).unwrap_or_default(); + } + #[cfg(target_os = "macos")] + { + event.platform_code = rdev::macos_keycode_from_key(k).unwrap_or_default() as _; + event.position_code = event.platform_code; + } + #[cfg(target_os = "linux")] + { + event.position_code = rdev::linux_keycode_from_key(k).unwrap_or_default(); + event.platform_code = event.position_code; + } + #[cfg(any(target_os = "android", target_os = "ios"))] + { + event.usb_hid = rdev::usb_hid_keycode_from_key(k).unwrap_or_default(); + } + event + } + + /// 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 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_normalized("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_normalized("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_normalized("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_normalized("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, + ); + } + + /// The native Flutter path turns a key into a name through its USB HID + /// usage (`rdev::usb_hid_key_from_code` -> `event_to_key_name`); the Dart + /// recorder does the same through `physicalKeyName`. Both are checked + /// against the same fixture, so a binding recorded on any keyboard layout + /// matches the key that was pressed. + #[test] + fn usb_hid_keys_match_fixture() { + let pairs: Vec<(String, u32)> = serde_json::from_str(include_str!( + "../../flutter/test/fixtures/shortcut_key_usb_hid.json" + )) + .expect("parse fixture"); + for (name, usage) in pairs { + let key = rdev::usb_hid_key_from_code(usage); + assert_eq!( + event_to_key_name(&make_press(key)).as_deref(), + Some(name.as_str()), + "USB HID {usage:#04x}" + ); + } + } + + /// Serializes the tests that write the global `CACHE`. + static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn reload_handles_missing_and_invalid_json() { + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // empty (no value set) → defaults + reload_from_raw("", "windows", false); + let b = current(); + assert!(!b.enabled); + assert!(b.bindings.is_empty()); + + // invalid JSON → defaults (no panic) + reload_from_raw("not json", "windows", false); + let b = current(); + assert!(!b.enabled); + } + + #[test] + fn wayland_binding_is_inactive_and_returns_on_x11() { + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let raw = r#"{ + "enabled": true, "pass_through": false, "bindings": [ + {"action":"toggle_relative_mouse_mode","mods":["primary"],"key":"r"}, + {"action":"toggle_mute","mods":["primary"],"key":"m"} + ] + }"#; + reload_from_raw(raw, "linux", false); + let original = current(); + for is_wayland in [true, false] { + reload_from_raw(raw, "linux", is_wayland); + let active = current(); + assert!(active.enabled); + assert!(!active.pass_through); + assert_eq!( + match_normalized("r", &[Modifier::Primary], &active), + if is_wayland { None } else { Some(action_id::TOGGLE_RELATIVE_MOUSE_MODE) }, + ); + assert_eq!(match_normalized("m", &[Modifier::Primary], &active), Some(action_id::TOGGLE_MUTE)); + } + assert_eq!(match_normalized("r", &[Modifier::Primary], &original), Some(action_id::TOGGLE_RELATIVE_MOUSE_MODE)); + } + + #[test] + fn active_cache_omits_platform_hidden_saved_actions() { + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let actions = [action_id::SWITCH_DISPLAY_ALL, action_id::KEYBOARD_MODE_MAP, + action_id::TOGGLE_SHOW_MY_CURSOR, action_id::TOGGLE_ZOOM_CURSOR, + action_id::TOGGLE_RECORDING, action_id::TOGGLE_VOICE_CALL, + action_id::RESET_CANVAS, action_id::TOGGLE_MUTE, action_id::SEND_CTRL_ALT_DEL]; + let saved = Bindings { enabled: true, pass_through: false, + bindings: actions.iter().map(|action| Binding { + action: (*action).into(), mods: vec![Modifier::Primary], key: "m".into(), + }).collect() }; + let raw = serde_json::to_string(&saved).unwrap(); + for (platform, expected) in [ + ("android", vec![action_id::TOGGLE_RECORDING, action_id::TOGGLE_VOICE_CALL, + action_id::RESET_CANVAS, action_id::TOGGLE_MUTE, action_id::SEND_CTRL_ALT_DEL]), + ("ios", vec![action_id::RESET_CANVAS, action_id::TOGGLE_MUTE, action_id::SEND_CTRL_ALT_DEL]), + ("macos", actions.iter().copied().filter(|a| *a != action_id::RESET_CANVAS).collect()), + ] { + reload_from_raw(&raw, platform, false); + let active = current(); + assert_eq!(active.bindings.iter().map(|b| b.action.as_str()).collect::>(), expected, "{platform}"); + } + assert_eq!(serde_json::from_str::(&raw).unwrap(), saved); + } + + #[cfg(feature = "flutter")] + fn make_release(k: rdev::Key) -> rdev::Event { + let mut e = make_press(k); + e.event_type = rdev::EventType::KeyRelease(k); + e + } + + /// Holding the chord modifiers and pressing two bound keys must fire both + /// actions once each: releasing the modifiers on the remote must not clear + /// them locally, and overlapping fired keys each own their repeats and + /// release. + #[cfg(feature = "flutter")] + #[test] + fn dispatch_consumes_overlapping_fired_keys() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + // Records the action of every press that dispatched: a press whose key + // was not already fired before the call. + let fired = std::cell::RefCell::new(Vec::new()); + let dispatch = |e: &rdev::Event| { + let before = fired_keys(); + let action = match_event(e); + let hit = try_dispatch(Some(&SID_A), e, "map", || "windows".into(), |_| {}); + if let rdev::EventType::KeyPress(k) = e.event_type { + if hit && !before.contains(&k) { + fired.borrow_mut().push(action.unwrap_or_default()); + } + } + hit + }; + + assert!(dispatch(&make_press(Key::KeyP))); + { + let state = super::super::MODIFIERS_STATE.lock().unwrap(); + for k in chord { + assert_eq!(state.get(&k), Some(&true), "{k:?} must stay held locally"); + } + } + assert!(dispatch(&make_press(Key::KeyC)), "second chord key fires"); + assert!(dispatch(&make_press(Key::KeyP)), "P repeat is consumed"); + assert!(dispatch(&make_release(Key::KeyP)), "P release is consumed"); + assert!(dispatch(&make_release(Key::KeyC)), "C release is consumed"); + assert!(fired_keys().is_empty()); + assert_eq!( + *fired.borrow(), + vec![action_id::SCREENSHOT.to_owned(), action_id::TOGGLE_CHAT.to_owned()] + ); + + release_chord(chord); + } + + /// A fired key stays owned until its release even after the modifiers go. + #[cfg(feature = "flutter")] + #[test] + fn dispatch_consumes_fired_key_after_modifiers_release() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + let dispatch = + |e: &rdev::Event| try_dispatch(Some(&SID_A), e, "map", || "windows".into(), |_| {}); + + assert!(dispatch(&make_press(Key::KeyP))); + release_chord(chord); + assert!(dispatch(&make_press(Key::KeyP)), "repeat without modifiers is consumed"); + assert!(dispatch(&make_release(Key::KeyP)), "release without modifiers is consumed"); + assert!(!dispatch(&make_press(Key::KeyP)), "a new press without the chord passes"); + assert!(!dispatch(&make_release(Key::KeyP))); + } + + /// Close tab is itself a shortcut: the session that fired the key is gone + /// before the key is released, and the session that receives the rest of + /// the key's events still owns them. + #[cfg(feature = "flutter")] + #[test] + fn dispatch_keeps_fired_key_after_session_close() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + let dispatch = |sid: &hbb_common::SessionID, e: &rdev::Event| { + try_dispatch(Some(sid), e, "map", || "windows".into(), |_| {}) + }; + + assert!(dispatch(&SID_A, &make_press(Key::KeyP))); + clear_session_state(&SID_A); + assert!(dispatch(&SID_B, &make_press(Key::KeyP)), "repeat stays consumed after A closed"); + release_chord(chord); + assert!(dispatch(&SID_B, &make_press(Key::KeyP)), "repeat without the chord too"); + assert!(dispatch(&SID_B, &make_release(Key::KeyP)), "release stays consumed after A closed"); + assert!(fired_keys().is_empty()); + assert!(!dispatch(&SID_B, &make_press(Key::KeyP)), "a new press without the chord passes"); + assert!(!dispatch(&SID_B, &make_release(Key::KeyP))); + } + + #[cfg(feature = "flutter")] + #[test] + fn dispatch_consumes_fired_key_after_session_switch() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + let previous_session = crate::flutter::get_cur_session_id(); + crate::flutter::set_cur_session_id(SID_A); + let dispatch = |e: &rdev::Event| { + try_dispatch(None, e, "map", || "windows".into(), |_| {}) + }; + + assert!(dispatch(&make_press(Key::KeyP))); + crate::flutter::set_cur_session_id(SID_B); + release_chord(chord); + assert!(dispatch(&make_press(Key::KeyP)), "repeat stays consumed in the new tab"); + clear_session_state(&SID_A); + crate::flutter::set_cur_session_id(SID_A); + assert!(dispatch(&make_press(Key::KeyP)), "repeat stays consumed after switching back"); + crate::flutter::set_cur_session_id(SID_B); + assert!(dispatch(&make_release(Key::KeyP)), "release stays consumed in the new tab"); + crate::flutter::set_cur_session_id(SID_A); + assert!(!dispatch(&make_press(Key::KeyP)), "new press is not a stale repeat"); + crate::flutter::set_cur_session_id(previous_session); + } + + #[cfg(feature = "flutter")] + #[test] + fn dispatch_preserves_modifier_cleanup_on_leave() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + assert!(try_dispatch( + Some(&SID_A), &make_press(Key::KeyP), "map", || "windows".into(), |_| {} + )); + super::super::release_remote_keys("map"); + assert_eq!( + super::super::client::get_modifiers_state(false, false, false, false), + (false, false, false, false), + "leaving the remote image must clear modifiers even after a shortcut" + ); + release_chord(chord); + reset_fired_keys(); + } + + #[cfg(feature = "flutter")] + #[test] + fn repeated_shortcuts_leave_remote_modifiers_released() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + let remote_held = std::cell::RefCell::new( + chord.into_iter().collect::>() + ); + for key in [Key::KeyP, Key::KeyC] { + assert!(try_dispatch( + Some(&SID_A), + &make_press(key), + "map", + || "windows".into(), + |event| { + let key = rdev::win_key_from_scancode(event.chr()); + assert!(chord.contains(&key), "only held modifiers are released"); + assert!( + key != Key::Alt || !event.down, + "pressing Alt again activates the Windows system menu and eats the next character" + ); + if event.down { + remote_held.borrow_mut().insert(key); + } else { + remote_held.borrow_mut().remove(&key); + } + }, + )); + assert!(remote_held.borrow().is_empty()); + } + release_chord(chord); + reset_fired_keys(); + } + + #[cfg(feature = "flutter")] + #[test] + fn shortcut_cleanup_keeps_unrelated_keys_held() { + use base::message_proto::KeyboardMode; + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + for action in [action_id::TOGGLE_MUTE, action_id::TOGGLE_CHAT, + action_id::TOGGLE_VIEW_ONLY, action_id::TOGGLE_SHOW_MY_CURSOR, + action_id::INSERT_LOCK, action_id::SEND_CTRL_ALT_DEL, + action_id::KEYBOARD_MODE_MAP, action_id::KEYBOARD_MODE_TRANSLATE, + action_id::KEYBOARD_MODE_LEGACY] + { + let chord = enable_defaults_and_hold_chord(); + let mut bindings = default_bindings(); + bindings.iter_mut().find(|b| b.key == "s").unwrap().action = action.into(); + *CACHE.write().unwrap() = Arc::new(Bindings { enabled: true, pass_through: false, bindings }); + release_chord(chord); + super::super::event_to_key_events( + "windows".into(), &make_press(Key::KeyW), KeyboardMode::Map, Some(0), + ); + for key in chord { + super::super::event_to_key_events( + "windows".into(), &make_press(key), KeyboardMode::Map, Some(0), + ); + } + let sent = std::cell::RefCell::new(Vec::new()); + let consumed = try_dispatch( + Some(&SID_A), &make_press(Key::KeyS), "map", || "windows".into(), + |event| sent.borrow_mut().push((rdev::win_key_from_scancode(event.chr()), event.down)), + ); + let tracked = super::super::TO_RELEASE.lock().unwrap().contains_key(&Key::KeyW); + super::super::event_to_key_events( + "windows".into(), &make_release(Key::KeyW), KeyboardMode::Map, Some(0), + ); + release_chord(chord); + reset_fired_keys(); + clear_session_state(&SID_A); + + assert!(consumed); + let keep_held = action == action_id::TOGGLE_MUTE; + assert_eq!(tracked, keep_held, "held key tracking after {action}"); + let w_events: Vec<_> = sent.borrow().iter() + .filter(|(key, _)| *key == Key::KeyW).map(|(_, down)| *down).collect(); + assert_eq!(w_events, if keep_held { vec![] } else { vec![false] }, "{action}"); + } + } + + #[cfg(feature = "flutter")] + #[test] + fn repeated_forwarded_key_does_not_start_shortcut() { + use base::message_proto::KeyboardMode; + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + release_chord(chord); + let press = make_press(Key::KeyS); + let release = make_release(Key::KeyS); + assert!(!try_dispatch(Some(&SID_A), &press, "map", || "windows".into(), |_| {})); + super::super::event_to_key_events("windows".into(), &press, KeyboardMode::Map, Some(0)); + for key in chord { + super::super::event_to_key_events("windows".into(), &make_press(key), KeyboardMode::Map, Some(0)); + } + let repeat_consumed = try_dispatch(Some(&SID_A), &press, "map", || "windows".into(), |_| {}); + let release_consumed = try_dispatch(Some(&SID_A), &release, "map", || "windows".into(), |_| {}); + let release_events = if release_consumed { vec![] } else { + super::super::event_to_key_events("windows".into(), &release, KeyboardMode::Map, Some(0)) + }; + // Cleanup precedes assertions so a failing regression cannot poison other tests. + super::super::event_to_key_events("windows".into(), &release, KeyboardMode::Map, Some(0)); + release_chord(chord); + reset_fired_keys(); + clear_session_state(&SID_A); + + assert!(!repeat_consumed, "a repeated forwarded key must not become a shortcut"); + assert!(!release_consumed, "the original keyup must still reach the peer"); + assert_eq!(release_events.len(), 1); + assert!(!release_events[0].down); + } + + #[cfg(feature = "flutter")] + #[test] + fn alt_only_shortcut_neutralizes_windows_menu() { + use base::message_proto::KeyEvent; + #[cfg(windows)] + use rdev::EventType; + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut results = Vec::new(); + for (mode, alt) in ["map", "translate", "legacy"].into_iter() + .flat_map(|mode| [Key::Alt, Key::AltGr].map(|alt| (mode, alt))) + { + #[cfg(any(target_os = "android", target_os = "ios"))] + if mode == "legacy" { continue; } // Mobile does not encode Legacy events. + #[cfg(target_os = "macos")] + if mode == "translate" && alt == Key::AltGr { continue; } // Right Option is filtered. + reload_from_raw(r#"{"enabled":true,"bindings":[{"action":"toggle_mute","mods":["alt"],"key":"m"}]}"#, "windows", false); + reset_fired_keys(); + clear_session_state(&SID_A); + let sent = std::cell::RefCell::new(Vec::new()); + let send = |event: &KeyEvent| { + sent.borrow_mut().push(windows_shortcut_modifier(event)); + }; + let handle = |event: &rdev::Event| { + #[cfg(windows)] + if matches!(event.event_type, EventType::KeyPress(k) | EventType::KeyRelease(k) if k == alt) { + rdev::set_modifier(alt, matches!(event.event_type, EventType::KeyPress(_))); + } + if !try_dispatch(Some(&SID_A), event, mode, || "windows".into(), &send) { + for key in super::super::event_to_key_events( + "windows".into(), event, super::super::get_keyboard_mode_enum(mode), Some(0), + ) { + send(&key); + } + } + }; + handle(&make_press(alt)); + handle(&make_press(Key::KeyM)); + handle(&make_press(Key::KeyM)); + handle(&make_release(Key::KeyM)); + handle(&make_release(alt)); + reset_fired_keys(); + clear_session_state(&SID_A); + results.push((mode, alt, sent.into_inner())); + } + assert!(results.iter().all(|(_, alt, sent)| *sent == vec![ + (*alt, true), (Key::ControlLeft, true), (*alt, false), + (Key::ControlLeft, false), (*alt, false), + ]), "the consumed M must not leave a bare Alt tap on Windows: {results:?}"); + } + + #[cfg(feature = "flutter")] + fn windows_shortcut_modifier(event: &base::message_proto::KeyEvent) -> (rdev::Key, bool) { + use base::message_proto::{key_event::Union, ControlKey}; + use rdev::Key; + let key = match event.union.as_ref() { + Some(Union::Chr(code)) => rdev::win_key_from_scancode(*code), + Some(Union::Win2winHotkey(code)) => rdev::win_key_from_keycode(*code >> 16), + Some(Union::ControlKey(key)) => match key.enum_value().unwrap() { + ControlKey::Alt => Key::Alt, + ControlKey::RAlt => Key::AltGr, + ControlKey::Control => Key::ControlLeft, + _ => panic!("unexpected modifier: {event:?}"), + }, + _ => panic!("unexpected shortcut output: {event:?}"), + }; + (key, event.down) + } + + #[cfg(feature = "flutter")] + #[test] + fn shortcut_cleanup_does_not_tap_alt() { + use base::message_proto::KeyboardMode; + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + for peer in ["windows", "linux", "macos"] { + let chord = enable_defaults_and_hold_chord(); + let alt = super::super::event_to_key_events( + peer.into(), &make_press(Key::Alt), KeyboardMode::Map, Some(0), + ); + assert_eq!(alt.len(), 1); + let sent = std::cell::RefCell::new(Vec::new()); + let consumed = try_dispatch( + Some(&SID_A), &make_press(Key::KeyS), "map", || peer.into(), + |event| { + if event.chr() == alt[0].chr() { sent.borrow_mut().push(event.down); } + }, + ); + release_chord(chord); + reset_fired_keys(); + clear_session_state(&SID_A); + + assert!(consumed); + assert_eq!(*sent.borrow(), vec![false], "shortcut cleanup must only release Alt on {peer}"); + } + } + + #[cfg(feature = "flutter")] + fn shortcut_followed_by_c(release_primary: bool) -> (rdev::Key, Vec<(rdev::Key, bool)>) { + use base::message_proto::KeyboardMode; + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_fired_keys(); + *CACHE.write().unwrap() = Arc::new(Bindings { + enabled: true, + pass_through: false, + bindings: vec![Binding { + action: action_id::SCREENSHOT.into(), + mods: vec![Modifier::Primary], + key: "p".into(), + }], + }); + let primary = if cfg!(any(target_os = "macos", target_os = "ios")) { + Key::MetaLeft + } else { + Key::ControlLeft + }; + let sent = std::cell::RefCell::new(Vec::new()); + let send = |event: &base::message_proto::KeyEvent| { + sent.borrow_mut().push((rdev::win_key_from_scancode(event.chr()), event.down)); + }; + let handle = |event: &rdev::Event| { + if !try_dispatch(Some(&SID_A), event, "map", || "windows".into(), &send) { + for key_event in super::super::event_to_key_events( + "windows".into(), event, KeyboardMode::Map, Some(0) + ) { + send(&key_event); + } + } + }; + handle(&make_press(primary)); + handle(&make_press(Key::KeyP)); + handle(&make_release(Key::KeyP)); + if release_primary { + handle(&make_release(primary)); + } + sent.borrow_mut().clear(); + handle(&make_press(Key::KeyC)); + let result = sent.borrow().clone(); + handle(&make_release(Key::KeyC)); + handle(&make_release(primary)); + reset_fired_keys(); + (primary, result) + } + + #[cfg(feature = "flutter")] + #[test] + fn unbound_key_restores_modifiers_held_after_shortcut() { + let (primary, sent) = shortcut_followed_by_c(false); + assert_eq!(sent, vec![(primary, true), (rdev::Key::KeyC, true)]); + } + + #[cfg(feature = "flutter")] + #[test] + fn unbound_key_does_not_restore_released_modifiers() { + let (_, sent) = shortcut_followed_by_c(true); + assert_eq!(sent, vec![(rdev::Key::KeyC, true)]); + } + + #[cfg(all(feature = "flutter", not(any(target_os = "android", target_os = "ios"))))] + #[test] + fn view_only_preserves_rdev_shortcut_keys() { + use rdev::Key; + use std::sync::atomic::Ordering; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let previous = super::super::IS_RDEV_ENABLED.swap(true, Ordering::SeqCst); + let chord = enable_defaults_and_hold_chord(); + assert!(try_dispatch( + Some(&SID_A), &make_press(Key::KeyP), "map", || "windows".into(), |_| {} + )); + enter_view_only(&SID_A); + let action = match_event(&make_press(Key::KeyC)); + let fired = fired_keys().contains(&Key::KeyP); + super::super::IS_RDEV_ENABLED.store(previous, Ordering::SeqCst); + release_chord(chord); + clear_session_state(&SID_A); + assert_eq!(action.as_deref(), Some(action_id::TOGGLE_CHAT)); + assert!(fired, "rdev still receives the held shortcut key's release"); + } + + #[cfg(all(feature = "flutter", not(any(target_os = "android", target_os = "ios"))))] + #[test] + fn view_only_clears_flutter_shortcut_keys_and_modifiers() { + use rdev::Key; + use std::sync::atomic::Ordering; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let previous = super::super::IS_RDEV_ENABLED.swap(false, Ordering::SeqCst); + let chord = enable_defaults_and_hold_chord(); + assert!(try_dispatch( + Some(&SID_A), &make_press(Key::KeyP), "map", || "windows".into(), |_| {} + )); + clear_session_state(&SID_B); + assert_eq!(match_event(&make_press(Key::KeyC)).as_deref(), Some(action_id::TOGGLE_CHAT)); + enter_view_only(&SID_A); + super::super::IS_RDEV_ENABLED.store(previous, Ordering::SeqCst); + assert!(fired_keys().is_empty()); + assert_eq!( + super::super::client::get_modifiers_state(false, false, false, false), + (false, false, false, false) + ); + assert!(!try_dispatch( + Some(&SID_A), &make_press(Key::KeyP), "map", || "windows".into(), + |_| panic!("view-only reset must not replay old modifiers") + )); + release_chord(chord); + } + + /// The Dart matcher keeps the same list (`kShortcutActionsRunOnKeyUp`); + /// the fixture is the shared source of truth. + #[test] + fn key_up_actions_match_fixture() { + use std::collections::BTreeSet; + + let fixture: Vec = serde_json::from_str(include_str!( + "../../flutter/test/fixtures/key_up_shortcut_actions.json" + )) + .expect("parse fixture"); + let expected: BTreeSet<&str> = fixture.iter().map(String::as_str).collect(); + let actual: BTreeSet<&str> = RELEASE_ACTION_IDS.iter().copied().collect(); + assert_eq!( + actual, expected, + "runs_on_release drifted from key_up_shortcut_actions.json — update \ + shortcuts.rs, the fixture, and Dart kShortcutActionsRunOnKeyUp together" + ); + for id in &fixture { + assert!(runs_on_release(id)); + } + assert!(!runs_on_release(action_id::SCREENSHOT)); + } + + /// Close tab and tab switching move focus away before the key is + /// released; they run on the release so the same matcher sees the whole + /// press. + #[cfg(feature = "flutter")] + #[test] + fn focus_changing_actions_run_on_release() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift]; + *CACHE.write().unwrap() = Arc::new(Bindings { + enabled: true, + pass_through: false, + bindings: vec![ + Binding { action: action_id::CLOSE_TAB.into(), mods: prefix(), key: "w".into() }, + Binding { action: action_id::SCREENSHOT.into(), mods: prefix(), key: "p".into() }, + ], + }); + let dispatch = |sid: &hbb_common::SessionID, e: &rdev::Event| { + try_dispatch(Some(sid), e, "map", || "windows".into(), |_| {}) + }; + let pending_for = |k: Key| RELEASE_ACTIONS.lock().unwrap().get(&k).cloned(); + + assert!(dispatch(&SID_A, &make_press(Key::KeyW))); + assert_eq!(pending_for(Key::KeyW), Some((SID_A, action_id::CLOSE_TAB.to_owned()))); + assert!(dispatch(&SID_A, &make_press(Key::KeyW)), "repeat is consumed"); + assert_eq!(pending_for(Key::KeyW), Some((SID_A, action_id::CLOSE_TAB.to_owned()))); + assert!(dispatch(&SID_B, &make_release(Key::KeyW)), "release is consumed wherever it lands"); + assert_eq!(pending_for(Key::KeyW), None, "the action ran on the release"); + + assert!(dispatch(&SID_A, &make_press(Key::KeyP))); + assert_eq!(pending_for(Key::KeyP), None, "other actions still run on the press"); + assert!(dispatch(&SID_A, &make_release(Key::KeyP))); + + assert!(dispatch(&SID_A, &make_press(Key::KeyW))); + clear_session_state(&SID_A); + assert_eq!(pending_for(Key::KeyW), None, "a closed session owes nothing"); + assert!(dispatch(&SID_A, &make_release(Key::KeyW)), "but its key stays owned"); + release_chord(chord); + } + + #[cfg(feature = "flutter")] + #[test] + fn clipboard_prompt_waits_for_release_before_taking_focus() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + *CACHE.write().unwrap() = Arc::new(Bindings { + enabled: true, + pass_through: false, + bindings: vec![Binding { + action: action_id::SEND_CLIPBOARD_KEYSTROKES.into(), + mods: vec![Modifier::Primary, Modifier::Alt, Modifier::Shift], + key: "v".into(), + }], + }); + let dispatch = |event: &rdev::Event| { + try_dispatch(Some(&SID_A), event, "map", || "linux".into(), |_| {}) + }; + for _ in 0..2 { + assert!(dispatch(&make_press(Key::KeyV))); + assert_eq!( + RELEASE_ACTIONS.lock().unwrap().get(&Key::KeyV).cloned(), + Some((SID_A, action_id::SEND_CLIPBOARD_KEYSTROKES.to_owned())), + "the prompt must not take focus before the trigger release" + ); + assert!(dispatch(&make_release(Key::KeyV))); + assert!(!fired_keys().contains(&Key::KeyV)); + assert!(!RELEASE_ACTIONS.lock().unwrap().contains_key(&Key::KeyV)); + } + release_chord(chord); + } + + #[cfg(feature = "flutter")] + const SID_A: hbb_common::SessionID = hbb_common::SessionID::from_u128(0xA); + #[cfg(feature = "flutter")] + const SID_B: hbb_common::SessionID = hbb_common::SessionID::from_u128(0xB); + + #[cfg(feature = "flutter")] + fn fired_keys() -> std::collections::HashSet { + FIRED_KEYS.lock().unwrap().clone() + } + + #[cfg(feature = "flutter")] + fn reset_fired_keys() { + FIRED_KEYS.lock().unwrap().clear(); + RELEASE_ACTIONS.lock().unwrap().clear(); + } + + #[cfg(feature = "flutter")] + fn enable_defaults_and_hold_chord() -> [rdev::Key; 3] { + use base::message_proto::KeyboardMode; + use rdev::Key; + + *CACHE.write().unwrap() = Arc::new(Bindings { + enabled: true, + pass_through: false, + bindings: default_bindings(), + }); + reset_fired_keys(); + clear_session_state(&SID_A); + clear_session_state(&SID_B); + let primary = if cfg!(any(target_os = "macos", target_os = "ios")) { + Key::MetaLeft + } else { + Key::ControlLeft + }; + let chord = [primary, Key::Alt, Key::ShiftLeft]; + for k in chord { + super::super::event_to_key_events("windows".into(), &make_press(k), KeyboardMode::Map, None); + } + chord + } + + #[cfg(feature = "flutter")] + fn release_chord(chord: [rdev::Key; 3]) { + use base::message_proto::KeyboardMode; + for k in chord { + super::super::event_to_key_events("windows".into(), &make_release(k), KeyboardMode::Map, None); + } + } +} diff --git a/src/lang/ar.rs b/src/lang/ar.rs index bf4d7880e..67a38764a 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "تعذّر على RustDesk تحميل مكوّن GStreamer اللازم لالتقاط الشاشة ({})"), ("Relay fallback delay in seconds", "مهلة التراجع إلى الترحيل بالثواني"), ("relay-fallback-delay-tip", "المدة التي ينتظرها اتصال الترحيل القائم بالفعل الاتصالَ المباشر عبر WebRTC قبل أن يُستخدم بدلًا منه. زِدها لمنح الاتصال المباشر البطيء فرصة أكبر للفوز؛ وقلّلها للاستقرار على الترحيل أسرع في الشبكات التي يتعذر فيها الاتصال المباشر. اتركها فارغة للقيمة الافتراضية 2.5 ثانية."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/az.rs b/src/lang/az.rs index 95a478b98..6811021d6 100644 --- a/src/lang/az.rs +++ b/src/lang/az.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", ""), ("Relay fallback delay in seconds", "Ötürücüyə keçid gecikməsi, saniyə"), ("relay-fallback-delay-tip", "Artıq qurulmuş ötürücü bağlantı birbaşa WebRTC bağlantısını nə qədər gözləyir, sonra onun əvəzinə istifadə olunur. Yavaş birbaşa bağlantıya daha çox vaxt vermək üçün artırın; birbaşa bağlantının mümkün olmadığı şəbəkələrdə ötürücüyə daha tez keçmək üçün azaldın. Standart 2.5 saniyə üçün boş buraxın."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 2e168c9f6..cec7de809 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не змог загрузіць кампанент GStreamer, патрэбны для захопу экрана ({})"), ("Relay fallback delay in seconds", "Затрымка пераходу на рэтранслятар у секундах"), ("relay-fallback-delay-tip", "Колькі часу ўжо ўсталяванае злучэнне праз рэтранслятар чакае прамога злучэння WebRTC, перш чым будзе выкарыстана замест яго. Павялічце, каб даць павольнаму прамому злучэнню больш часу; паменшыце, каб хутчэй пераходзіць на рэтранслятар у сетках, дзе прамое злучэнне немагчымае. Пакіньце пустым для значэння па змаўчанні 2.5 секунды."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 61cfed7b8..98836b209 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не можа да зареди компонент на GStreamer, необходим за заснемане на екрана ({})"), ("Relay fallback delay in seconds", "Забавяне преди преминаване към препредаване в секунди"), ("relay-fallback-delay-tip", "Колко време вече установената връзка чрез препредаване изчаква директната WebRTC връзка, преди да бъде използвана вместо нея. Увеличете, за да дадете повече време на бавна директна връзка; намалете, за да се премине по-бързо към препредаване в мрежи, където директна връзка е невъзможна. Оставете празно за стойността по подразбиране 2.5 секунди."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 29e013ced..55ff85020 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "El RustDesk no ha pogut carregar un component del GStreamer necessari per capturar la pantalla ({})"), ("Relay fallback delay in seconds", "Retard abans de recórrer al relé en segons"), ("relay-fallback-delay-tip", "Quant de temps espera una connexió de relé ja establerta la connexió directa WebRTC abans d'utilitzar-se en lloc seu. Augmenteu-lo per donar més temps a una connexió directa lenta; reduïu-lo per passar abans al relé en xarxes on no es pot fer una connexió directa. Deixeu-lo buit per al valor predeterminat de 2.5 segons."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 02c47f5b0..bf277975d 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk 无法加载屏幕捕获所需的 GStreamer 组件 ({})"), ("Relay fallback delay in seconds", "回落到中继前的等待时间(秒)"), ("relay-fallback-delay-tip", "已经建立的中继连接会等待直连的 WebRTC 多久,超过这个时间就改用中继。调大可以让较慢的直连有更多机会胜出;调小则在无法直连的网络上更快回落到中继。留空表示使用默认值 2.5 秒。"), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "开始语音通话前请在\"共享屏幕\"页面启用\"音频录制\"。") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "开始语音通话前请在\"共享屏幕\"页面启用\"音频录制\"。"), + ("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", "切换到上一个显示器"), + ("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(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 643ad9b0c..635b2d7c6 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nemohl načíst komponentu GStreameru potřebnou k zachycení obrazovky ({})"), ("Relay fallback delay in seconds", "Prodleva před přepnutím na přenos v sekundách"), ("relay-fallback-delay-tip", "Jak dlouho již navázané spojení přes přenos čeká na přímé spojení WebRTC, než bude použito místo něj. Zvyšte, aby pomalé přímé spojení mělo více času uspět; snižte, aby se v sítích, kde přímé spojení není možné, dříve přešlo na přenos. Ponechte prázdné pro výchozí hodnotu 2.5 sekundy."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index d0fb6a1ba..926b0c5b4 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunne ikke indlæse en GStreamer-komponent, der kræves til skærmoptagelse ({})"), ("Relay fallback delay in seconds", "Forsinkelse før brug af relæ i sekunder"), ("relay-fallback-delay-tip", "Hvor længe en allerede oprettet relæforbindelse venter på den direkte WebRTC-forbindelse, før den bruges i stedet. Forøg for at give en langsom direkte forbindelse mere tid; sænk for hurtigere at falde tilbage til relæet på netværk, hvor en direkte forbindelse ikke kan oprettes. Lad feltet stå tomt for standardværdien 2.5 sekunder."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index e4f9f6889..2c7bdad39 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk konnte eine für die Bildschirmaufnahme benötigte GStreamer-Komponente nicht laden ({})"), ("Relay fallback delay in seconds", "Verzögerung bis zum Relais in Sekunden"), ("relay-fallback-delay-tip", "Wie lange eine bereits aufgebaute Relaisverbindung auf die direkte WebRTC-Verbindung wartet, bevor sie stattdessen verwendet wird. Erhöhen Sie den Wert, um einer langsamen direkten Verbindung mehr Zeit zu geben; verringern Sie ihn, um in Netzwerken ohne mögliche Direktverbindung schneller auf das Relais zurückzufallen. Leer lassen für den Standardwert von 2.5 Sekunden."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index a8b36474d..2ad67b754 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "Το RustDesk δεν μπόρεσε να φορτώσει ένα στοιχείο του GStreamer που απαιτείται για την καταγραφή οθόνης ({})"), ("Relay fallback delay in seconds", "Καθυστέρηση πριν από τη χρήση αναμεταδότη σε δευτερόλεπτα"), ("relay-fallback-delay-tip", "Πόσο χρόνο περιμένει μια ήδη ενεργή σύνδεση αναμεταδότη την απευθείας σύνδεση WebRTC πριν χρησιμοποιηθεί στη θέση της. Αυξήστε το για να δώσετε σε μια αργή απευθείας σύνδεση περισσότερο χρόνο. Μειώστε το για ταχύτερη επιστροφή στον αναμεταδότη σε δίκτυα όπου δεν είναι δυνατή η απευθείας σύνδεση. Αφήστε το κενό για την προεπιλογή των 2.5 δευτερολέπτων."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index ca61f35a1..98010d877 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -279,5 +279,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", "An app in the terminal wants to copy text to this device's clipboard. If granted, this permission applies to terminal apps in all connections until you turn it off in Settings. Manual copy and paste are unaffected."), ("port-forward-mux-tip", "Carry every connection of a port-forward mapping over a single connection to the peer, instead of connecting and logging in again for each one."), ("relay-fallback-delay-tip", "How long a relay connection that is already up waits for the direct WebRTC connection before it is used instead. Raise it to give a slow direct connection more time to win; lower it to settle on the relay sooner on networks where a direct connection cannot be made. Leave empty for the default of 2.5 seconds."), + ("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(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 2518d7a7d..edf05091b 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ne povis ŝargi komponanton de GStreamer necesan por ekrankapto ({})"), ("Relay fallback delay in seconds", "Prokrasto antaŭ retransmisio en sekundoj"), ("relay-fallback-delay-tip", "Kiom longe jam establita retransmisia konekto atendas la rektan WebRTC-konekton antaŭ ol esti uzata anstataŭe. Pligrandigu ĝin por doni al malrapida rekta konekto pli da tempo; malpligrandigu ĝin por pli frue uzi la retransmision en retoj kie rekta konekto ne eblas. Lasu malplena por la defaŭlta valoro de 2.5 sekundoj."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 8b8fbaa43..efc91b9b8 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk no ha podido cargar un componente de GStreamer necesario para capturar la pantalla ({})"), ("Relay fallback delay in seconds", "Retardo antes de usar el relé en segundos"), ("relay-fallback-delay-tip", "Cuánto tiempo espera una conexión de relé ya establecida a la conexión directa WebRTC antes de usarse en su lugar. Auméntelo para dar más tiempo a una conexión directa lenta; redúzcalo para recurrir antes al relé en redes donde no es posible una conexión directa. Déjelo vacío para el valor predeterminado de 2.5 segundos."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Para comenzar una llamada de voz activar \"Captura de audio\" en la página \"Compartir pantalla\".") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Para comenzar una llamada de voz activar \"Captura de audio\" en la página \"Compartir pantalla\"."), + ("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", ""), + ("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(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 9eb426b72..edaee1c05 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ei suutnud laadida ekraani jäädvustamiseks vajalikku GStreameri komponenti ({})"), ("Relay fallback delay in seconds", "Viivitus enne relee kasutamist sekundites"), ("relay-fallback-delay-tip", "Kui kaua juba loodud releeühendus ootab otsest WebRTC-ühendust, enne kui seda selle asemel kasutatakse. Suurendage, et anda aeglasele otseühendusele rohkem aega; vähendage, et võrkudes, kus otseühendust luua ei saa, releele kiiremini üle minna. Jätke tühjaks vaikeväärtuse 2.5 sekundit kasutamiseks."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index ac1dad3d3..5a2ae9f05 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk-ek ezin izan du pantaila kapturatzeko beharrezkoa den GStreamer osagai bat kargatu ({})"), ("Relay fallback delay in seconds", "Errelera itzultzeko atzerapena segundotan"), ("relay-fallback-delay-tip", "Dagoeneko ezarritako errele-konexio batek WebRTC konexio zuzenari zenbat denbora itxaroten dion, haren ordez erabili aurretik. Handitu konexio zuzen motel bati denbora gehiago emateko; txikitu konexio zuzena egin ezin den sareetan lehenago errelera itzultzeko. Utzi hutsik 2.5 segundoko balio lehenetsirako."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index dc93ac8df..17b1b7717 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk نتوانست مؤلفه GStreamer موردنیاز برای ضبط صفحه را بارگذاری کند ({})"), ("Relay fallback delay in seconds", "تأخیر بازگشت به رله بر حسب ثانیه"), ("relay-fallback-delay-tip", "یک اتصال رله که از قبل برقرار شده چقدر منتظر اتصال مستقیم WebRTC می ماند پیش از آنکه به جای آن استفاده شود. آن را افزایش دهید تا به اتصال مستقیم کند فرصت بیشتری داده شود؛ کاهش دهید تا در شبکه هایی که اتصال مستقیم ممکن نیست، زودتر به رله بازگردد. برای مقدار پیش فرض 2.5 ثانیه خالی بگذارید."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 4f1367403..cfc05abed 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ei voinut ladata näytön kaappaukseen tarvittavaa GStreamer-osaa ({})"), ("Relay fallback delay in seconds", "Viive ennen välitykseen siirtymistä sekunteina"), ("relay-fallback-delay-tip", "Kuinka kauan jo muodostettu välitysyhteys odottaa suoraa WebRTC-yhteyttä ennen kuin sitä käytetään sen sijaan. Kasvata arvoa antaaksesi hitaalle suoralle yhteydelle enemmän aikaa; pienennä sitä siirtyäksesi nopeammin välitykseen verkoissa, joissa suoraa yhteyttä ei voi muodostaa. Jätä tyhjäksi käyttääksesi oletusarvoa 2.5 sekuntia."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 661f0434a..df9ce68c4 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk n'a pas pu charger un composant GStreamer nécessaire à la capture d'écran ({})"), ("Relay fallback delay in seconds", "Délai avant bascule vers le relais en secondes"), ("relay-fallback-delay-tip", "Durée pendant laquelle une connexion relais déjà établie attend la connexion directe WebRTC avant d'être utilisée à sa place. Augmentez-la pour laisser plus de temps à une connexion directe lente ; diminuez-la pour basculer plus tôt vers le relais sur les réseaux où une connexion directe est impossible. Laissez vide pour la valeur par défaut de 2.5 secondes."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index e5295306c..78dd11b8e 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk-მა ვერ ჩატვირთა ეკრანის ჩაწერისთვის საჭირო GStreamer-ის კომპონენტი ({})"), ("Relay fallback delay in seconds", "რელეზე გადასვლის დაყოვნება წამებში"), ("relay-fallback-delay-tip", "რამდენ ხანს ელოდება უკვე დამყარებული რელე-კავშირი პირდაპირ WebRTC კავშირს, სანამ მის ნაცვლად გამოიყენება. გაზარდეთ, რომ ნელ პირდაპირ კავშირს მეტი დრო მისცეთ; შეამცირეთ, რომ ქსელებში, სადაც პირდაპირი კავშირი შეუძლებელია, უფრო სწრაფად გადავიდეს რელეზე. დატოვეთ ცარიელი ნაგულისხმევი 2.5 წამისთვის."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/gl.rs b/src/lang/gl.rs index a3fd74569..2868ffbc6 100644 --- a/src/lang/gl.rs +++ b/src/lang/gl.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk non puido cargar un compoñente de GStreamer necesario para capturar a pantalla ({})"), ("Relay fallback delay in seconds", "Atraso antes de usar o relé en segundos"), ("relay-fallback-delay-tip", "Canto tempo agarda unha conexión de relé xa establecida pola conexión directa WebRTC antes de usarse no seu lugar. Auménteo para darlle máis tempo a unha conexión directa lenta; redúzao para recorrer antes ao relé en redes onde non é posible unha conexión directa. Déixeo baleiro para o valor predeterminado de 2.5 segundos."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index aec5aa9cf..128f47516 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk સ્ક્રીન કૅપ્ચર માટે જરૂરી GStreamer ઘટક લોડ કરી શક્યું નથી ({})"), ("Relay fallback delay in seconds", "રિલે પર પાછા ફરવામાં વિલંબ સેકન્ડમાં"), ("relay-fallback-delay-tip", "પહેલેથી સ્થાપિત રિલે કનેક્શન સીધા WebRTC કનેક્શનની કેટલો સમય રાહ જુએ છે, ત્યાર બાદ તેના બદલે વપરાય છે. ધીમા સીધા કનેક્શનને વધુ સમય આપવા માટે વધારો; જ્યાં સીધું કનેક્શન શક્ય નથી તેવા નેટવર્ક પર વહેલા રિલે પર જવા માટે ઘટાડો. મૂળભૂત 2.5 સેકન્ડ માટે ખાલી રાખો."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 46f6fa70a..0d6d7e7d0 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk לא הצליח לטעון רכיב GStreamer הדרוש ללכידת מסך ({})"), ("Relay fallback delay in seconds", "השהיה לפני מעבר לממסר בשניות"), ("relay-fallback-delay-tip", "כמה זמן חיבור ממסר שכבר נוצר ממתין לחיבור WebRTC הישיר לפני שישמש במקומו. הגדל כדי לתת לחיבור ישיר איטי יותר זמן; הקטן כדי לעבור מהר יותר לממסר ברשתות שבהן לא ניתן ליצור חיבור ישיר. השאר ריק לערך ברירת המחדל של 2.5 שניות."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 90db3a41f..3c35d9c05 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk स्क्रीन कैप्चर के लिए आवश्यक GStreamer घटक लोड नहीं कर सका ({})"), ("Relay fallback delay in seconds", "रिले पर लौटने में विलंब सेकंड में"), ("relay-fallback-delay-tip", "पहले से स्थापित रिले कनेक्शन सीधे WebRTC कनेक्शन की कितनी देर प्रतीक्षा करता है, उसके बाद उसके स्थान पर उपयोग किया जाता है। धीमे सीधे कनेक्शन को अधिक समय देने के लिए बढ़ाएँ; जिन नेटवर्क पर सीधा कनेक्शन संभव नहीं है वहाँ जल्दी रिले पर जाने के लिए घटाएँ। डिफ़ॉल्ट 2.5 सेकंड के लिए खाली छोड़ें।"), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index f74e0cd63..17f28791e 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nije mogao učitati GStreamer komponentu potrebnu za snimanje zaslona ({})"), ("Relay fallback delay in seconds", "Odgoda prije prelaska na relej u sekundama"), ("relay-fallback-delay-tip", "Koliko dugo već uspostavljena relejna veza čeka izravnu WebRTC vezu prije nego što se upotrijebi umjesto nje. Povećajte da sporoj izravnoj vezi date više vremena; smanjite da se na mrežama gdje izravna veza nije moguća brže prijeđe na relej. Ostavite prazno za zadanu vrijednost od 2.5 sekunde."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 4b40737d8..6a2449bf5 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "A RustDesk nem tudta betölteni a képernyőrögzítéshez szükséges GStreamer összetevőt ({})"), ("Relay fallback delay in seconds", "Késleltetés a továbbítóra váltás előtt másodpercben"), ("relay-fallback-delay-tip", "Mennyi ideig vár a már létrejött továbbító kapcsolat a közvetlen WebRTC kapcsolatra, mielőtt helyette használnák. Növelje, hogy a lassú közvetlen kapcsolatnak több ideje legyen; csökkentse, hogy olyan hálózatokon, ahol közvetlen kapcsolat nem hozható létre, hamarabb váltson továbbítóra. Hagyja üresen az alapértelmezett 2.5 másodperchez."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index c5c6dad25..99e96c47f 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk tidak dapat memuat komponen GStreamer yang diperlukan untuk merekam layar ({})"), ("Relay fallback delay in seconds", "Jeda sebelum beralih ke relai dalam detik"), ("relay-fallback-delay-tip", "Berapa lama koneksi relai yang sudah terbentuk menunggu koneksi langsung WebRTC sebelum digunakan sebagai gantinya. Perbesar untuk memberi koneksi langsung yang lambat lebih banyak waktu; perkecil agar lebih cepat beralih ke relai pada jaringan yang tidak memungkinkan koneksi langsung. Biarkan kosong untuk nilai bawaan 2.5 detik."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index ef91cb167..c014bb70b 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk non è riuscito a caricare un componente GStreamer necessario per l'acquisizione dello schermo ({})"), ("Relay fallback delay in seconds", "Ritardo fallback relay (secondi)"), ("relay-fallback-delay-tip", "Quanto tempo una connessione relay già attiva attende la connessione WebRTC diretta prima di essere usata. Aumentalo per dare a una connessione diretta lenta più tempo per funzionare; diminuiscilo per passare prima al relay sulle reti in cui non è possibile effettuare una connessione diretta. Lascia vuoto per il valore predefinito di 2,5 secondi."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Per avviare una chiamata vocale, attiva nella pagina 'Condividi schermo' la voce 'Cattura audio'.") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Per avviare una chiamata vocale, attiva nella pagina 'Condividi schermo' la voce 'Cattura audio'."), + ("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", ""), + ("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(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 40395503f..32ec2df45 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk は画面キャプチャに必要な GStreamer コンポーネントを読み込めませんでした ({})"), ("Relay fallback delay in seconds", "中継に切り替えるまでの待ち時間 (秒)"), ("relay-fallback-delay-tip", "すでに確立された中継接続が、直接の WebRTC 接続をどれだけ待ってから代わりに使用されるかを指定します。値を大きくすると遅い直接接続に時間を与えられ、小さくすると直接接続できないネットワークで早く中継に切り替わります。空欄にすると既定値の 2.5 秒になります。"), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index c5e577f62..7eaafbab7 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk가 화면 캡처에 필요한 GStreamer 구성 요소를 불러오지 못했습니다 ({})"), ("Relay fallback delay in seconds", "릴레이 대체 작동 지연 시간 (초)"), ("relay-fallback-delay-tip", "이미 연결된 중계 연결이 직접 WebRTC 연결을 얼마나 기다린 후 대신 사용되는지입니다. 값을 늘리면 느린 직접 연결에 더 많은 시간을 주고, 줄이면 직접 연결이 불가능한 네트워크에서 더 빨리 중계로 전환합니다. 비워 두면 기본값 2.5초가 사용됩니다."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "음성 통화를 시작하려면 '화면 공유' 페이지에서 '오디오 캡처'를 사용함으로 하세요.") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "음성 통화를 시작하려면 '화면 공유' 페이지에서 '오디오 캡처'를 사용함으로 하세요."), + ("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", ""), + ("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(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index e28232a3f..3a83f9a35 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk экранды түсіру үшін қажет GStreamer компонентін жүктей алмады ({})"), ("Relay fallback delay in seconds", "Релеге ауысу кідірісі, секундпен"), ("relay-fallback-delay-tip", "Бұрыннан орнатылған реле байланысы тікелей WebRTC байланысын қанша уақыт күтеді, содан кейін оның орнына қолданылады. Баяу тікелей байланысқа көбірек уақыт беру үшін үлкейтіңіз; тікелей байланыс мүмкін емес желілерде релеге тезірек ауысу үшін кішірейтіңіз. Әдепкі 2.5 секунд үшін бос қалдырыңыз."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 1d3f30b87..e7c387cba 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nepavyko įkelti ekrano įrašymui reikalingo GStreamer komponento ({})"), ("Relay fallback delay in seconds", "Delsa prieš pereinant prie perdavimo sekundėmis"), ("relay-fallback-delay-tip", "Kiek laiko jau užmegztas perdavimo ryšys laukia tiesioginio WebRTC ryšio, kol bus panaudotas vietoj jo. Padidinkite, kad lėtam tiesioginiam ryšiui būtų skirta daugiau laiko; sumažinkite, kad tinkluose, kuriuose tiesioginis ryšys neįmanomas, greičiau būtų pereinama prie perdavimo. Palikite tuščią numatytajai 2.5 sekundės reikšmei."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 28a71803d..16690dbf8 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nevarēja ielādēt ekrāna tveršanai nepieciešamo GStreamer komponentu ({})"), ("Relay fallback delay in seconds", "Aizkave pirms pārslēgšanās uz retranslatoru sekundēs"), ("relay-fallback-delay-tip", "Cik ilgi jau izveidots retranslatora savienojums gaida tiešo WebRTC savienojumu, pirms tiek izmantots tā vietā. Palieliniet, lai lēnam tiešajam savienojumam dotu vairāk laika; samaziniet, lai tīklos, kur tiešais savienojums nav iespējams, ātrāk pārslēgtos uz retranslatoru. Atstājiet tukšu noklusējuma 2.5 sekunžu vērtībai."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index ac8785aee..076f2628f 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "സ്ക്രീൻ പകർത്താൻ ആവശ്യമായ GStreamer ഘടകം RustDesk-ന് ലോഡ് ചെയ്യാനായില്ല ({})"), ("Relay fallback delay in seconds", "റിലേയിലേക്ക് മാറുന്നതിനുള്ള കാലതാമസം സെക്കൻഡിൽ"), ("relay-fallback-delay-tip", "ഇതിനകം സ്ഥാപിതമായ റിലേ കണക്ഷൻ നേരിട്ടുള്ള WebRTC കണക്ഷനായി എത്ര നേരം കാത്തിരിക്കുന്നു, അതിനുശേഷം അതിനുപകരം ഉപയോഗിക്കുന്നു. മന്ദഗതിയിലുള്ള നേരിട്ടുള്ള കണക്ഷന് കൂടുതൽ സമയം നൽകാൻ വർദ്ധിപ്പിക്കുക; നേരിട്ടുള്ള കണക്ഷൻ സാധ്യമല്ലാത്ത നെറ്റ്‌വർക്കുകളിൽ വേഗത്തിൽ റിലേയിലേക്ക് മാറാൻ കുറയ്ക്കുക. സ്ഥിരസ്ഥിതിയായ 2.5 സെക്കൻഡിനായി ശൂന്യമാക്കിയിടുക."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 86b0ac427..fda6f66c5 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunne ikke laste en GStreamer-komponent som kreves for skjermopptak ({})"), ("Relay fallback delay in seconds", "Forsinkelse før bruk av relé i sekunder"), ("relay-fallback-delay-tip", "Hvor lenge en allerede opprettet reléforbindelse venter på den direkte WebRTC-forbindelsen før den brukes i stedet. Øk verdien for å gi en treg direkteforbindelse mer tid; senk den for å gå raskere over til reléet på nettverk der direkte forbindelse ikke er mulig. La stå tom for standardverdien på 2.5 sekunder."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index ad23606ee..f3aa1358c 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kon een GStreamer-component die nodig is voor schermopname niet laden ({})"), ("Relay fallback delay in seconds", "Vertraging voordat relay wordt gebruikt in seconden"), ("relay-fallback-delay-tip", "Hoe lang een al tot stand gekomen relayverbinding wacht op de directe WebRTC-verbinding voordat deze in plaats daarvan wordt gebruikt. Verhoog de waarde om een trage directe verbinding meer tijd te geven; verlaag deze om op netwerken waar een directe verbinding niet mogelijk is sneller op de relay terug te vallen. Laat leeg voor de standaardwaarde van 2.5 seconden."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Om een spraakgesprek te starten, schakel je \"Audio-opname\" in op de pagina \"Scherm delen\".") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Om een spraakgesprek te starten, schakel je \"Audio-opname\" in op de pagina \"Scherm delen\"."), + ("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", ""), + ("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(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index a62aa5abe..c66ff09c4 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nie mógł załadować składnika GStreamer wymaganego do przechwytywania ekranu ({})"), ("Relay fallback delay in seconds", "Opóźnienie przed przejściem na przekaźnik w sekundach"), ("relay-fallback-delay-tip", "Jak długo nawiązane już połączenie przez przekaźnik czeka na bezpośrednie połączenie WebRTC, zanim zostanie użyte zamiast niego. Zwiększ, aby dać wolnemu połączeniu bezpośredniemu więcej czasu; zmniejsz, aby w sieciach, w których połączenie bezpośrednie jest niemożliwe, szybciej przechodzić na przekaźnik. Pozostaw puste, aby użyć wartości domyślnej 2.5 sekundy."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Aby rozpocząć połączenie głosowe, włącz opcję \"Przechwytywanie dźwięku\" na stronie \"Udostępnianie ekranu\".") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Aby rozpocząć połączenie głosowe, włącz opcję \"Przechwytywanie dźwięku\" na stronie \"Udostępnianie ekranu\"."), + ("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", ""), + ("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(); } diff --git a/src/lang/pt_BR.rs b/src/lang/pt_BR.rs index 0d1323b3a..373bd26c4 100644 --- a/src/lang/pt_BR.rs +++ b/src/lang/pt_BR.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para a captura de tela ({})."), ("Relay fallback delay in seconds", "Atraso antes de recorrer ao retransmissor em segundos"), ("relay-fallback-delay-tip", "Tempo que a conexão de retransmissão aguarda pela conexão direta WebRTC. Aumente para dar mais tempo a conexões lentas; diminua para usar o retransmissor mais cedo. Deixe vazio para usar o padrão de 2,5 segundos."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Para iniciar uma chamada de voz, ative \"Captura de áudio\" na página \"Compartilhar Tela\".") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Para iniciar uma chamada de voz, ative \"Captura de áudio\" na página \"Compartilhar Tela\"."), + ("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", ""), + ("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(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 9579c567e..f741573c9 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para capturar o ecrã ({})"), ("Relay fallback delay in seconds", "Atraso antes de recorrer ao retransmissor em segundos"), ("relay-fallback-delay-tip", "Quanto tempo uma ligação de retransmissão já estabelecida aguarda pela ligação direta WebRTC antes de ser usada em vez dela. Aumente para dar mais tempo a uma ligação direta lenta; diminua para recorrer mais cedo ao retransmissor em redes onde não é possível uma ligação direta. Deixe vazio para o valor predefinido de 2.5 segundos."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Para iniciar uma chamada de voz, ative \"Captura de Áudio\" na página \"Partilhar ecrã\".") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Para iniciar uma chamada de voz, ative \"Captura de Áudio\" na página \"Partilhar ecrã\"."), + ("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", ""), + ("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(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 3c3caca86..2458acf6b 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nu a putut încărca o componentă GStreamer necesară pentru capturarea ecranului ({})"), ("Relay fallback delay in seconds", "Întârziere înainte de trecerea la releu în secunde"), ("relay-fallback-delay-tip", "Cât timp așteaptă o conexiune prin releu deja stabilită conexiunea directă WebRTC înainte de a fi folosită în locul ei. Măriți valoarea pentru a acorda mai mult timp unei conexiuni directe lente; micșorați-o pentru a trece mai repede la releu în rețelele în care o conexiune directă nu este posibilă. Lăsați gol pentru valoarea implicită de 2.5 secunde."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 46af796cb..a3d1bb7a6 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не удалось загрузить компонент GStreamer, необходимый для захвата экрана ({})"), ("Relay fallback delay in seconds", "Задержка перед переходом на ретранслятор в секундах"), ("relay-fallback-delay-tip", "Сколько времени уже установленное соединение через ретранслятор ждёт прямое соединение WebRTC, прежде чем будет использовано вместо него. Увеличьте, чтобы дать медленному прямому соединению больше времени; уменьшите, чтобы быстрее переходить на ретранслятор в сетях, где прямое соединение невозможно. Оставьте пустым для значения по умолчанию 2.5 секунды."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Чтобы начать голосовой вызов, включите \"Захват аудио\" на странице \"Демонстрация экрана\".") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Чтобы начать голосовой вызов, включите \"Захват аудио\" на странице \"Демонстрация экрана\"."), + ("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", ""), + ("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(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 0097a9cd9..6791db5e4 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk no at pòdidu carrigare unu cumponente de GStreamer netzessàriu pro registrare sa schermada ({})"), ("Relay fallback delay in seconds", "Tardu prima de impreare su relè in segundos"), ("relay-fallback-delay-tip", "Cantu tempus una connessione de relè giai istabilida abetat sa connessione direta WebRTC prima de èssere impreada in su postu suo. Aumenta pro dare prus tempus a una connessione direta lenta; diminuì pro colare prima a su relè in sas retes in ue non si podet fàghere una connessione direta. Lassa bòidu pro su valore predefinidu de 2.5 segundos."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Pro aviare una mutida de boghe, ativa in sa pàgina \"Cumpartzi ischermu\" s’elementu \"Catura de s’àudio\".") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Pro aviare una mutida de boghe, ativa in sa pàgina \"Cumpartzi ischermu\" s’elementu \"Catura de s’àudio\"."), + ("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", ""), + ("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(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 91bda91f3..a1f424116 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nedokázal načítať komponent GStreamera potrebný na zachytenie obrazovky ({})"), ("Relay fallback delay in seconds", "Oneskorenie pred prepnutím na prenos v sekundách"), ("relay-fallback-delay-tip", "Ako dlho už nadviazané spojenie cez prenos čaká na priame spojenie WebRTC, kým sa použije namiesto neho. Zvýšte, aby pomalé priame spojenie malo viac času; znížte, aby sa v sieťach, kde priame spojenie nie je možné, skôr prešlo na prenos. Nechajte prázdne pre predvolenú hodnotu 2.5 sekundy."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 82eaae19a..910c7fea4 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ni mogel naložiti komponente GStreamer, potrebne za zajem zaslona ({})"), ("Relay fallback delay in seconds", "Zakasnitev pred preklopom na posrednika v sekundah"), ("relay-fallback-delay-tip", "Kako dolgo že vzpostavljena posredniška povezava čaka na neposredno povezavo WebRTC, preden se uporabi namesto nje. Povečajte, da počasni neposredni povezavi date več časa; zmanjšajte, da v omrežjih, kjer neposredna povezava ni mogoča, hitreje preklopite na posrednika. Pustite prazno za privzeto vrednost 2.5 sekunde."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index e0c5a6b51..b2d1688f0 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nuk mundi të ngarkojë një komponent të GStreamer të nevojshëm për regjistrimin e ekranit ({})"), ("Relay fallback delay in seconds", "Vonesa para kalimit te releja në sekonda"), ("relay-fallback-delay-tip", "Sa gjatë pret një lidhje releje tashmë e vendosur lidhjen e drejtpërdrejtë WebRTC përpara se të përdoret në vend të saj. Rriteni për t'i dhënë më shumë kohë një lidhjeje të drejtpërdrejtë të ngadaltë; uleni për të kaluar më shpejt te releja në rrjete ku lidhja e drejtpërdrejtë nuk është e mundur. Lëreni bosh për vlerën e parazgjedhur prej 2.5 sekondash."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index e30975604..4992a2518 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk nije mogao da učita GStreamer komponentu potrebnu za snimanje ekrana ({})"), ("Relay fallback delay in seconds", "Кашњење пре преласка на релеј у секундама"), ("relay-fallback-delay-tip", "Колико дуго већ успостављена релејна веза чека на директну WebRTC везу пре него што се употреби уместо ње. Повећајте да бисте спорој директној вези дали више времена; смањите да бисте на мрежама где директна веза није могућа брже прешли на релеј. Оставите празно за подразумевану вредност од 2.5 секунде."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 846ffd6ec..0aab13b68 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk kunde inte läsa in en GStreamer-komponent som krävs för skärminspelning ({})"), ("Relay fallback delay in seconds", "Fördröjning innan relä används i sekunder"), ("relay-fallback-delay-tip", "Hur länge en redan upprättad reläanslutning väntar på den direkta WebRTC-anslutningen innan den används i stället. Öka värdet för att ge en långsam direktanslutning mer tid; sänk det för att snabbare falla tillbaka på reläet i nätverk där direktanslutning inte är möjlig. Lämna tomt för standardvärdet 2.5 sekunder."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 51c3394d3..2f0b37069 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "திரைப் பதிவுக்குத் தேவையான GStreamer கூறை RustDesk ஏற்ற முடியவில்லை ({})"), ("Relay fallback delay in seconds", "ரிலேக்கு மாறுவதற்கான தாமதம் வினாடிகளில்"), ("relay-fallback-delay-tip", "ஏற்கனவே நிறுவப்பட்ட ரிலே இணைப்பு நேரடி WebRTC இணைப்புக்காக எவ்வளவு நேரம் காத்திருக்கிறது, அதன் பிறகு அதற்குப் பதிலாகப் பயன்படுத்தப்படுகிறது. மெதுவான நேரடி இணைப்புக்கு அதிக நேரம் வழங்க அதிகரிக்கவும்; நேரடி இணைப்பு சாத்தியமில்லாத பிணையங்களில் விரைவாக ரிலேக்கு மாற குறைக்கவும். இயல்புநிலை 2.5 வினாடிகளுக்கு காலியாக விடவும்."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 31e3f5007..70b737117 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", ""), ("Relay fallback delay in seconds", ""), ("relay-fallback-delay-tip", ""), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index e032343f4..a7baf3b9c 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ไม่สามารถโหลดส่วนประกอบ GStreamer ที่จำเป็นสำหรับการบันทึกหน้าจอได้ ({})"), ("Relay fallback delay in seconds", "เวลารอก่อนเปลี่ยนไปใช้รีเลย์ (วินาที)"), ("relay-fallback-delay-tip", "การเชื่อมต่อผ่านรีเลย์ที่สร้างไว้แล้วจะรอการเชื่อมต่อ WebRTC โดยตรงนานเท่าใดก่อนที่จะถูกใช้แทน เพิ่มค่าเพื่อให้การเชื่อมต่อโดยตรงที่ช้ามีเวลามากขึ้น ลดค่าเพื่อเปลี่ยนไปใช้รีเลย์เร็วขึ้นในเครือข่ายที่ไม่สามารถเชื่อมต่อโดยตรงได้ เว้นว่างไว้เพื่อใช้ค่าเริ่มต้น 2.5 วินาที"), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 727ce2faf..792d968f3 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk ekran yakalama için gereken GStreamer bileşenini yükleyemedi ({})"), ("Relay fallback delay in seconds", "Aktarıcıya geçiş gecikmesi (saniye)"), ("relay-fallback-delay-tip", "Zaten kurulmuş bir aktarıcı bağlantısının, onun yerine kullanılmadan önce doğrudan WebRTC bağlantısını ne kadar beklediğidir. Yavaş bir doğrudan bağlantıya daha fazla süre tanımak için artırın; doğrudan bağlantının kurulamadığı ağlarda aktarıcıya daha erken geçmek için azaltın. Varsayılan 2.5 saniye için boş bırakın."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Sesli arama başlatmak için \"Ekranı Paylaş\" sayfasındaki \"Ses yakalama\" seçeneğini açın.") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Sesli arama başlatmak için \"Ekranı Paylaş\" sayfasındaki \"Ses yakalama\" seçeneğini açın."), + ("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", ""), + ("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(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 24472743a..8c42d8eae 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk 無法載入螢幕擷取所需的 GStreamer 元件 ({})"), ("Relay fallback delay in seconds", "回退到中繼前的等待時間(秒)"), ("relay-fallback-delay-tip", "已經建立的中繼連線會等待直連的 WebRTC 多久,超過這個時間就改用中繼。調大可以讓較慢的直連有更多機會勝出;調小則在無法直連的網路上更快回退到中繼。留空表示使用預設值 2.5 秒。"), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "若要開始語音通話,請在「螢幕分享」頁面啟用「音訊錄製」。") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "若要開始語音通話,請在「螢幕分享」頁面啟用「音訊錄製」。"), + ("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", ""), + ("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(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 1330cb07a..caece340a 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -781,5 +781,37 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay fallback delay in seconds", "Затримка перед переходом на ретранслятор у секундах"), ("relay-fallback-delay-tip", "Скільки часу вже встановлене з'єднання через ретранслятор чекає на пряме з'єднання WebRTC, перш ніж буде використане замість нього. Збільште, щоб дати повільному прямому з'єднанню більше часу; зменште, щоб швидше переходити на ретранслятор у мережах, де пряме з'єднання неможливе. Залиште порожнім для типового значення 2.5 секунди."), ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "Щоб розпочати голосовий виклик, увімкніть «Захоплення аудіо» на сторінці «Поділитися екраном»."), + ("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", ""), + ("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(); } diff --git a/src/lang/ur.rs b/src/lang/ur.rs index d5a65f1e9..f43d89ef4 100644 --- a/src/lang/ur.rs +++ b/src/lang/ur.rs @@ -780,7 +780,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk اسکرین ریکارڈنگ کے لیے درکار GStreamer جزو لوڈ نہیں کر سکا ({})"), ("Relay fallback delay in seconds", "ریلے پر واپس جانے میں تاخیر سیکنڈ میں"), ("relay-fallback-delay-tip", "پہلے سے قائم ریلے کنکشن براہ راست WebRTC کنکشن کا کتنی دیر انتظار کرتا ہے، اس کے بعد اس کی جگہ استعمال ہوتا ہے۔ سست براہ راست کنکشن کو مزید وقت دینے کے لیے بڑھائیں؛ ان نیٹ ورکس پر جہاں براہ راست کنکشن ممکن نہیں، جلد ریلے پر جانے کے لیے کم کریں۔ پہلے سے طے شدہ 2.5 سیکنڈ کے لیے خالی چھوڑ دیں۔"), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 38cdbd5d2..3427c1903 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -780,6 +780,38 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk không thể tải một thành phần GStreamer cần cho việc ghi màn hình ({})"), ("Relay fallback delay in seconds", "Độ trễ trước khi chuyển sang trung chuyển (giây)"), ("relay-fallback-delay-tip", "Kết nối trung chuyển đã thiết lập sẽ chờ kết nối WebRTC trực tiếp trong bao lâu trước khi được dùng thay thế. Tăng giá trị để cho kết nối trực tiếp chậm thêm thời gian; giảm để chuyển sang trung chuyển sớm hơn trên các mạng không thể kết nối trực tiếp. Để trống để dùng giá trị mặc định 2.5 giây."), - ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", "") + ("To start a voice call, enable \"Audio capture\" on the \"Share screen\" page.", ""), + ("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", ""), + ("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(); } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 013c05307..e45b4ef5e 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -302,6 +302,35 @@ thread_local! { static DISPLAY: RefCell<*mut c_void> = RefCell::new(unsafe { XOpenDisplay(std::ptr::null())}); } +pub fn right_alt_is_level3_shift() -> ResultType { + use hbb_common::x11::{keysym, xlib}; + const XKB_USE_CORE_KBD: c_uint = 0x0100; + // A fresh connection avoids Xlib's stale keymap cache after a layout switch. + // This query runs only for a matched chord with physical Right Alt held. + let display = unsafe { xlib::XOpenDisplay(std::ptr::null()) }; + if display.is_null() { + bail!("X display unavailable"); + } + let result = (|| { + let code = rdev::linux_keycode_from_key(rdev::Key::AltGr) + .ok_or_else(|| anyhow!("Right Alt has no X keycode"))?; + let mut state: xlib::XkbStateRec = unsafe { std::mem::zeroed() }; + let status = unsafe { xlib::XkbGetState(display, XKB_USE_CORE_KBD, &mut state) }; + if status != c_int::from(xlib::Success) { + bail!("XkbGetState failed: {}", status); + } + let symbol = unsafe { + xlib::XkbKeycodeToKeysym(display, code as _, state.group.into(), 0) + }; + // Physical Right Alt is ordinary Alt on layouts such as US English. + Ok(matches!(symbol as u32, keysym::XK_ISO_Level3_Shift + | keysym::XK_ISO_Level3_Latch | keysym::XK_ISO_Level3_Lock + | keysym::XK_Mode_switch)) + })(); + unsafe { xlib::XCloseDisplay(display) }; + result +} + // X11 error event structure for the custom error handler. // See: https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Using-the-Default-Error-Handlers #[repr(C)] diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index abe38272f..e2b6dc879 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -23,7 +23,7 @@ use hbb_common::{ sync::mpsc, time::{Duration as TokioDuration, Instant}, }, - whoami, Stream, + whoami, SessionID, Stream, }; use rdev::{Event, EventType::*, KeyCode}; #[cfg(all(feature = "vram", feature = "flutter"))] @@ -921,6 +921,7 @@ impl Session { #[cfg(any(target_os = "ios"))] pub fn handle_flutter_raw_key_event( &self, + _session_id: SessionID, _keyboard_mode: &str, _name: &str, _platform_code: i32, @@ -933,6 +934,7 @@ impl Session { #[cfg(not(any(target_os = "ios")))] pub fn handle_flutter_raw_key_event( &self, + session_id: SessionID, keyboard_mode: &str, name: &str, platform_code: i32, @@ -944,6 +946,7 @@ impl Session { self._handle_key_flutter_simulation(keyboard_mode, platform_code, down_or_up); } else { self._handle_raw_key_non_flutter_simulation( + session_id, keyboard_mode, platform_code, position_code, @@ -956,6 +959,7 @@ impl Session { #[cfg(not(any(target_os = "ios")))] fn _handle_raw_key_non_flutter_simulation( &self, + session_id: SessionID, keyboard_mode: &str, platform_code: i32, position_code: i32, @@ -989,11 +993,18 @@ impl Session { #[cfg(any(target_os = "windows", target_os = "macos"))] extra_data: 0, }; - keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self); + keyboard::client::process_event_with_session( + keyboard_mode, + &event, + Some(lock_modes), + self, + session_id, + ); } pub fn handle_flutter_key_event( &self, + session_id: SessionID, keyboard_mode: &str, character: &str, usb_hid: i32, @@ -1004,6 +1015,7 @@ impl Session { self._handle_key_flutter_simulation(keyboard_mode, usb_hid, down_or_up); } else { self._handle_key_non_flutter_simulation( + session_id, keyboard_mode, character, usb_hid, @@ -1039,6 +1051,7 @@ impl Session { fn _handle_key_non_flutter_simulation( &self, + session_id: SessionID, keyboard_mode: &str, character: &str, usb_hid: i32, @@ -1100,7 +1113,13 @@ impl Session { #[cfg(any(target_os = "windows", target_os = "macos"))] extra_data: 0, }; - keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self); + keyboard::client::process_event_with_session( + keyboard_mode, + &event, + Some(lock_modes), + self, + session_id, + ); } // flutter only TODO new input @@ -1949,7 +1968,9 @@ impl Session { self.send_key_event(&crate::keyboard::client::event_lock_screen()); } pub fn ctrl_alt_del(&self) { - self.send_key_event(&crate::keyboard::client::event_ctrl_alt_del()); + self.send_key_event(&crate::keyboard::client::event_ctrl_alt_del( + &self.peer_platform(), + )); } }