* fix(clipboard): unix file server, bound range reads The Range request fields nPositionLow and cbRequested are UINT32s carried in i32 fields, but were sign-extended to u64. A negative cbRequested became a near-u64::MAX length, `offset + length` wrapped past the clamp, and the server tried to allocate a u64::MAX buffer, aborting the process. Sign-extending nPositionLow also rejected offsets whose low word is >= 2 GiB, breaking reads past 2 GiB in large files. - decode nPositionLow and cbRequested as u32 - clamp with `length > file.size - offset` (offset <= size is checked above) - allocate the read buffer fallibly and return an error instead of aborting Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(enigo): win, scale absolute mouse moves without overflow mouse_move_to multiplied the peer-supplied coordinate by 65535 in i32, which overflows for large values, and divided by the virtual screen size, which panics if the metrics come back as 0. Scale in i64, clamp to i32, and skip the move when the virtual screen size is unavailable. Results for inputs that did not overflow are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(agents): avoid unthrottled high-frequency debug logs Debug output is written to the log file, so per-packet / per-event log sites must use trace or hbb_common's throttled_log!. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): unix file server, reject range reads over the frame limit A response larger than MAX_FRAME_LENGTH (1 GiB) cannot be encoded for sending, yet the server allocated and read it first, then failed the send. Reject such requests before allocating, so a peer can no longer make the server allocate more than 1 GiB per request. Requests that work today are unaffected. Drop the extra allocation-failure log; the caller already logs the error. Tests cover the wire decoding through read_file_contents (negative cbRequested, nPositionLow past 2 GiB) and the frame-limit rejection. Tests that use the global CLIP_FILES now take a shared lock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): keep file transfers on the file list they started from A file-contents request names a file only by its index, and the side that owns the files resolved it against whatever it held at that moment. On Windows 10+ Explorer's clipboard also offers FileContents, which the owner re-read from the live clipboard for every chunk, so copying another file mid-transfer spliced the new file into the one being pasted (#16332). A second paste, or another connection's paste, replaced the list the same way, and on macOS/Linux sync_files replaced CLIP_FILES on every host copy. Each request now names its list in the existing clip_data_id field: an FNV-1a hash of the file-descriptor PDU, which both sides already hold. Owners keep the last 4 lists they sent and serve a request from the list it names; an unknown id fails instead of reading another file. Peers that send no id read the list last sent to their connection, and a Windows owner stops reading the live clipboard once it has changed since the list was built. Tests cover a host copy and a second paste during a transfer, a list id sent to another connection, and the hash's reference vectors. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): unix file server, cap a single range read at 16 MiB A peer could still make the server allocate and read up to the 1 GiB frame limit per request. Cap the data a single range read returns at 16 MiB. The read is clamped to the rest of the file first, and a larger one is refused rather than shortened: a short response reads as end of file to IStream callers on Windows and would silently truncate the copy. macOS requests 4 MiB per range (BLOCK_SIZE) and FUSE reads are smaller. A Windows client forwards the application's IStream::Read size, so only an application reading more than 16 MiB in one call is affected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(enigo): win, warn (throttled) when a mouse move is skipped A missing virtual screen size is a fault that should show up in a user's log, but mouse_move_to runs per input event. Log it at warn at most once a minute via throttled_log! instead of at trace. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): review fixes for the file lists kept per transfer unix owner: - A retired list closes its files and 8 MiB read buffers; they reopen, from offset 0, on the next read. - The new list is built aside and swapped in only on success, so a failed rebuild leaves the current list in place. - A rebuild with an unchanged id keeps its peers on the new list instead of retiring a duplicate, so re-copying a directory no longer pushes out a list that a transfer is still reading. Windows: - One served-list slot per list, recording every connection it was sent to, so pastes of the same list do not use up slots. - A request for an earlier list is served before OleGetClipboard, so a busy clipboard cannot fail it. - An IStream read over 16 MiB is split into requests the unix file server accepts. Reads up to 16 MiB take the unchanged path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): give each copied file list its own id The list id hashed only the descriptors, so two copies with identical names, sizes and times (the same file in two folders) shared an id and a transfer could read the other copy's files. Each owner now writes a random nonce into the first descriptor's clsid, inside the reserved bytes every peer skips; no owner sets FD_CLSID. The paste side hashes what it received, so it needs no change, and older peers ignore the bytes. Re-sending the same paths with the same descriptors keeps the nonce, and the id. Windows owner: - Serve a request from its list before the ownership check when the list is no longer current or the host clipboard now holds files from a peer, so another controller copying onto the host no longer fails in-flight transfers. Only lists sent to that connection are served. - Evict the list sent least recently instead of in insertion order. unix owner: take everything from the crate in one use block. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard,enigo): bound served-list reads, clamp absolute moves wf_cliprdr: the served-list branch runs before the ownership check, yet it allocated the peer's cbRequested (up to 4 GiB) before looking the list up. Accept only FILECONTENTS_SIZE or a range of at most 16 MiB there, which is all that clients sending an id ever request, and fail anything else before allocating. enigo: clamp the scaled absolute coordinate to 0..=65535, the range MOUSEEVENTF_ABSOLUTE takes, instead of to the i32 range. Coordinates on the virtual desktop give the same result as before; off-desktop ones now land on its edge. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): keep a file list while it is being read, throttle read errors A unix paste side fetches every new file list as soon as the host copies, so each copy retires the previous list, and four copies during one long transfer evicted the list it was still reading. A kept list that serves a read now becomes the most recent (unix: back of the retired queue, Windows: last_served), so the next eviction takes a list nobody is reading. A failed file-contents read logged an error per request, and peers can now trigger new ones (an unknown list id, an over-limit range). Log it through throttled_log! instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): unix, keep retired lists in the order they were sent A peer that sends no list id is served the list last sent to its connection, found by searching the retired queue from the back. That only holds while the queue stays in retirement order, which is the order lists were sent. The previous commit moved a list to the back on every read, so another connection reading an older list could make a peer's next chunk come from that list: a transfer of B continued with A's bytes. Keep the queue in retirement order and track recency in a separate last_used stamp, set on retirement and on each read. A full queue evicts the list with the lowest stamp, so a list still being read is kept. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): unix, keep files being read open when their list is retired Retiring a list closed every file handle, so the next read of a file part-way through reopened it by path. If that path had been replaced meanwhile (an editor saving over it), the rest of the transfer came from the replacement: AAAABBBB arrived as AAAADDDD. Close only files that are not being read, such as the preloaded next file; a file part-way through keeps its handle and offset, and so its identity. Tests read two files of one list alternately, retire the list, replace both paths by rename, and check each file continues with its original bytes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): cap Windows ranges, expire idle kept lists, check file identity Windows owner: refuse every FILECONTENTS_RANGE over 16 MiB before allocating. Only the served-list branch was capped; requests without an id still allocated whatever the peer asked for, up to 4 GiB. Both owners: a kept (retired / non-current) file list that nobody has read for 5 minutes is dropped, with its open files; its transfer is over or its peer gone. The current list never expires, as before. On Windows, reads of the current list through the existing path now also refresh its kept copy, so a long transfer does not lose its list once a newer one is sent. Windows owner: old-list reads compare the file's size and last-write time with the descriptor that was sent, on the handle they read from, and fail on a mismatch instead of reading whatever file now has that path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(clipboard): unix, check list expiry without backdating an Instant an_idle_retired_list_expires backdated a timestamp with Instant::now().checked_sub(TTL + 1s), which is None while the monotonic clock is under about five minutes, as on a freshly booted CI machine; the list then never expired and the test failed. expire_retired now takes the current time, and the test passes a later one instead. Callers pass Instant::now(), so behavior is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(clipboard): drop read-time refresh, idle expiry and rebuild reuse; tighten checks Remove the read-time recency and the 5-minute idle expiry on both owners, and the unix reuse of an unchanged rebuild (has_same_files). They covered corner cases, added state, and each brought regressions of its own. Kept lists are again evicted oldest first (unix) or least recently sent (Windows), 4 at most; a transfer that outlives 4 newer lists fails cleanly. Windows: serve kept lists after the existing ownership check again, so a request carrying a list id no longer bypasses it while the host clipboard holds files from another session. Refuse request types other than size or range before allocating. unix: a retired list's file must still match the size and modified time it was listed with, checked on the handle the read uses; a file replaced at its path before its read now fails instead of being served. Tests: drop the four tests of the removed behavior, add a_retired_list_refuses_a_file_replaced_before_its_read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(agents): handle corner cases raised in review with small fixes or docs A refactor added to cover a corner case rarely converges: new counters, timestamps and expiry rules interact with state existing code relies on, and the next review round finds the problems they introduced. Fix a corner case when the fix is a few lines with no new state; otherwise document it as a known limit, and ask before anything larger. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
11 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.
Logging
-
debugand above are written to the log file. A log call that can fire repeatedly (per packet, frame, input event, or loop iteration, or at a rate a peer controls) must not usedebugor higher unthrottled. -
For such a site, pick one:
log::trace!when the event is expected and the line only helps while actively debugging;hbb_common::throttled_log!(interval, level, ...)when it signals a fault that should still show up in a user's log. It keeps one line per interval with a count of the rest. Usehbb_common::log_throttle::LogThrottledirectly only when the decision drives more than one log call.
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.
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.
Corner cases raised in review
A refactor added to cover a corner case rarely converges. Each new counter, timestamp, cache or eviction/expiry rule interacts with state that existing code relies on, and the next review round finds the problems it introduced.
- A corner case is still worth fixing when the fix is easy and low-risk: a local change of a few lines that adds no state and changes no existing lookup, such as moving a check or refusing bad input earlier.
- When the only fix needs new state, a new lifecycle rule or a restructure, and the code already fails cleanly there or behaves as master does, document it as a known limit in the PR instead. Anything beyond the easy fix needs the maintainer's explicit go-ahead first.
- Before adding state that reorders, expires or reuses existing data, list every lookup that reads that data and check each one still holds.
- Prefer a clean failure, where the operation reports an error, over machinery that tries to make a rare case succeed.
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.