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.
12 KiB
RustDesk Guide
Project Layout
Directory Structure
src/Rust appsrc/server/audio / clipboard / input / video / networksrc/platform/platform-specific codesrc/ui/legacy Sciter UI (deprecated)flutter/current UIlibs/hbb_common/shared with the server: rendezvous proto, sockets,Configcorelibs/base/(cratebase) client-only: option keys, message proto, file transfer, platform codelibs/scrap/screen capturelibs/enigo/input controllibs/clipboard/clipboardlibs/base/src/config/keys.rsthe single import path for all options
Key Components
- Remote Desktop Protocol: Custom protocol implemented in
src/rendezvous_mediator.rsfor communicating with rustdesk-server - Screen Capture: Platform-specific screen capture in
libs/scrap/ - Input Handling: Cross-platform input simulation in
libs/enigo/ - Audio/Video Services: Real-time audio/video streaming in
src/server/ - File Transfer: Secure file transfer implementation in
libs/base/src/fs.rs
hbb_common is a git submodule shared with the server, so changing it costs a
round-trip. Put client-only code in libs/base instead; it is a normal
workspace member. base::config::keys re-exports the handful of keys
hbb_common still reads, so callers get the whole set from that one path.
UI Architecture
- Legacy UI: Sciter-based (deprecated) - files in
src/ui/ - Modern UI: Flutter-based - files in
flutter/- Desktop:
flutter/lib/desktop/ - Mobile:
flutter/lib/mobile/ - Shared:
flutter/lib/common/andflutter/lib/models/
- Desktop:
Rust Rules
-
Avoid
unwrap()/expect()in production code. -
Exceptions:
- tests;
- lock acquisition where failure means poisoning, not normal control flow.
-
Otherwise prefer
Result+?or explicit handling. -
Do not ignore errors silently.
-
Avoid unnecessary
.clone(). -
Prefer borrowing when practical.
-
Do not add dependencies unless needed.
-
Keep code simple and idiomatic.
Tokio Rules
- Assume a Tokio runtime already exists.
- Never create nested runtimes.
- Never call
Runtime::block_on()inside Tokio / async code. - Do not hide runtime creation inside helpers or libraries.
- Do not hold locks across
.await. - Prefer
.await,tokio::spawn, channels. - Use
spawn_blockingor 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.dartdirectly. Follow the existing patterns there forSyncReturn<T>/Future<T>and thedart:jsglue. - 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 throughflutter/lib/web/bridge.dart, which usesdart:jsto call JS-side functions and to register Dart-side callbacks onwindow.*.
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.dartis the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes throughbind. Don't sprinkleif (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 inmainInit); the JS side callswindow.onFooBar(...). SeeonLoadAbFinished,onLoadGroupFinishedfor reference. - The maintainer cannot easily run
flutter_rust_bridge_codegen, so when a new FFI function lands insrc/flutter_ffi.rs:- add the Web counterpart to
flutter/lib/web/bridge.dartby hand; - 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.
- add the Web counterpart to
Editing Hygiene
- Change only what is required.
- Prefer the smallest valid diff.
- Do not refactor unrelated code.
- Do not make formatting-only changes.
- Keep naming/style consistent with nearby code.
Imports
-
One
useper crate. Everything a file takes from the same crate goes in a single braced block, not one statement per item:// no use base::fs; use base::message_proto::*; // yes use base::{fs, message_proto::*}; -
The only reason to split is a
#[cfg(...)]that does not apply to the whole block -- an attribute binds to one item, so a differently-gated import has to stand on its own. Apub usere-export likewise cannot join a plainuse.#[cfg(not(feature = "flutter"))] use base::fs; use base::message_proto::*; -
When splitting an existing
usebecause some of its items moved to another crate, fold each side into that crate's existing block rather than leaving a second statement behind.
Comments
- Avoid comments unless they explain a non-obvious reason, constraint, or workaround.
- Never restate what the code does; prefer clearer code instead.
- If the code is self-explanatory, add no comment.
Be minimally invasive
- Prefer purely additive changes: layer new (
#[cfg]-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none. - Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding).
- Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
- Put new logic in self-contained functions in the module it belongs to (platform-specific logic in
src/platform/, withuseinside the function body to avoid churning shared import blocks). Call sites in shared files (src/tray.rs,src/core_main.rs,src/server/connection.rs, …) should be thin one-line hooks.
Scope check before touching shared code
- Before changing a shared trait, a shared struct, or the signature of a widely used function, check whether the bug or feature is specific to one path. If it is, keep the change inside that path unless that is impossible, and say in the PR why it was.
- If an unrelated caller needs
Default::default(),None, or another placeholder solely to satisfy a signature you changed, the diff is too broad: stop and redesign. - The expected shape of a fix is a new function in the feature's own module, plus at most a new field or a thin hook in the shared code it needs. Feature-specific state belongs beside the feature's existing state, not in a new abstraction every caller has to learn.
Mandatory regression-surface check
Before considering any implementation complete, perform a minimization pass over the final diff.
- Inspect every modified existing file and every modified existing code path. Each must be strictly necessary for the requested change. Revert changes that are merely cleanup, refactoring, consistency improvements, or fixes for pre-existing issues.
- For new features, preserve the existing implementation path when the feature is disabled or unsupported whenever practical.
feature offshould run the old code, not a rewritten equivalent. - Do not route existing behavior through a new abstraction merely to share code with the new feature. Prefer a parallel new function or a small amount of duplication over changing a proven existing path.
- Keep new implementation logic in new or feature-specific modules. Changes to shared/core files should normally be thin hooks, capability checks, or protocol plumbing.
- Do not fix unrelated pre-existing bugs in the same PR. Put them in a separate change unless they directly block correctness or security of the requested work.
- For submodule bumps, inspect the exact commit range and ensure unrelated changes are not being pulled into the parent PR.
- Before finalizing, explicitly report the regression surface: list the existing files and existing runtime paths whose behavior changed, and explain why each change is unavoidable.
- During review, treat an unnecessarily modified legacy path as a review finding even if tests pass and the rewritten behavior appears equivalent.
Reviewing a PR
- Review only what the diff introduces. Verify ownership with
gh pr diffbefore reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's. - List pre-existing problems in a separate section at the end, or leave out the ones that are not fatal. Never mix them into the findings the author has to fix.
- Before re-reviewing, read the author's reply comments. Do not re-raise items they declined on scope grounds.
- State a finding's consequence exactly: distinguish "the value is lost" from "the shortcut is inert but the value still saves".
Localization (src/lang/*.rs)
Each file is a HashMap<key, translation>. Layout:
template.rsis the master list of every key. Never edit it as part of translation work.en.rsholds only the keys whose English display text differs from the key itself.- Every other file (
de.rs,fr.rs, …) carries the full key set; an untranslated entry has an empty value:("key", ""). it.rsis maintained by hand by its translator. Never fill or change its entries; when adding new keys, append them to it with""and leave the translation to the maintainer.
Finding the English source for a key
When filling an empty entry, determine the source English text with this rule:
- If
keyexists inen.rswith a non-empty value, that value is the source text (look it up inen.rs). - Otherwise the key string itself is the source text (the key is already plain English).
Then translate that source into the file's target language (infer the language from the file's existing non-empty entries / filename).
Translation hygiene
- Only fill empty values. Never change keys, and never touch existing non-empty translations.
- Preserve placeholders (
{}) and escape sequences (\n,\") exactly as in the source. - Do not translate brand or technical tokens:
RustDesk,Socks5,TLS,UAC,Wayland,X11,TCP,UDP,2FA,RDP,D3D, etc. - Copy URL values (e.g.
doc_*keys) verbatim fromen.rs.
Adding new keys (feature work)
- New English-text keys use sentence case, not Title Case:
Use ID whitelisting, notUse ID Whitelisting. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g.Use IP Whitelisting) stay as-is — do not rename them. - Since the key itself is the English display text, a sentence-case key usually needs no
en.rsentry; add one only when the display text must differ from the key (e.g.*_tipkeys). - Append each new key to
template.rs(with"") and to everysrc/lang/*.rsfile (translated, or""if unsure; always""forit.rs), at the end of the list.