Commit Graph
11494 Commits
Author SHA1 Message Date
rustdeskandClaude Fable 5.1 8102f728f1 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 20:01:39 +08:00
rustdeskandClaude Fable 5.1 63654263ed 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 20:01:12 +08:00
rustdeskandClaude Fable 5.1 001bdbcdd9 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 20:00:50 +08:00
rustdesk 4a60d221c9 fix(shortcuts): preserve input state and refresh action availability 2026-09-23 18:00:53 +08:00
rustdeskandClaude Opus 5.5 c69b2a60e6 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 18:00:53 +08:00
rustdeskandClaude Opus 5.5 63fc0a3b20 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 18:00:53 +08:00
rustdeskandClaude Opus 5.5 228bc7e586 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 18:00:53 +08:00
rustdeskandClaude Opus 5.5 c133948a91 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 18:00:53 +08:00
rustdeskandClaude Opus 5.5 e6704be093 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 18:00:53 +08:00
rustdeskandClaude Opus 5.5 4387e1eb3d langs: add shortcut keys to az, gl, ur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
2026-09-23 18:00:53 +08:00
fufesou 7899f2fb16 fix(keyboard): iPad, icon
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-23 18:00:53 +08:00
fufesou 1e82f7bb44 feat(keyboard): shortcuts, debug web
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-23 18:00:53 +08:00
fufesou cd9d6abd87 feat(keyboard): shortcuts, release keys before shortcut callback
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-23 18:00:53 +08:00
fufesou a4be67e9be feat(keyboard): shortcuts, color of "Reset to defaults"
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-23 18:00:53 +08:00
fufesou f6ed6f7cd8 fix(keyboard): shortcuts, harden config and callback lifecycle
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-23 18:00:53 +08:00
rustdesk 26b9f37eb0 langs 2026-09-23 18:00:53 +08:00
rustdesk e872f6acaa feat(shortcuts): user-configurable keyboard shortcuts for session actions
Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
  parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
  session actions. Bindings are stored in LocalConfig under
  `keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
  `pass_through` flags so flipping the master switch off is a hard stop.

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

  Design principles applied during review:

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

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

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

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

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

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

  Out-of-scope but included:
  - AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
    Web target's hand-written TS client, since both are load-bearing for
    any new FFI work.
  - remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
    monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
2026-09-23 18:00:29 +08:00
rustdesk 0efa6151ef fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848) 2026-09-23 18:00:28 +08:00
a8ae0d6d66 Review and complete pt_PT.rs translation (European Portuguese) (#16311)
* Update pt_PT.rs

* Update pt_PT.rs

* Update src/lang/pt_PT.rs fiz misspelling of "Apenas"

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Fix config_screen: replace trailing slash with period

Per CodeRabbit's suggestion — the slash was rendered directly in the
permission prompt text, not as a line continuation, so it read as a
stray character instead of proper punctuation.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix terminal-clipboard-write-tip: translate full 3-sentence source text

Per Greptile's review — the previous translation for this key only
covered a short summary (matching an equally incomplete ptbr.rs entry),
not the full English source defined in en.rs, which explains the
permission's scope (persists across all connections until disabled in
Settings) and that manual copy/paste is unaffected. Translated from the
actual en.rs source text as required by AGENTS.md's localization
guidelines.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-23 17:31:06 +08:00
asereze 2e333f64a4 Update sc.rs (#16314)
* Update sc.rs

* Update sc.rs
2026-09-23 17:30:47 +08:00
RustDeskandClaude Opus 5.5 e424b57fca Kx v1 (#16326)
* key exchange version 1 on the rendezvous and peer handshakes

Bump hbb_common for key exchange version 1, one stream key per direction,
and negotiate it on both handshakes.

Rendezvous: `secure_tcp` reads the version the server advertises in
`KeyExchange.version`, answers with the highest both speak, splits the key
when that is at least 1, and arms the check of the server's echo on its first
encrypted message.

Peer: the controlled side advertises `KX_VERSION_LATEST` inside the signed
`IdPk`, the controller answers in `PublicKey.kx_version` and both split the
key when the pick is at least 1. A pick above what was offered is refused as
a bug or tampering. `decode_id_pk_dtls` returns the advertised version along
with the fingerprint.

An absent field on either side is version 0, so any old peer or server keeps
today's stream byte for byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* bump hbb_common: keep the advertisement check armed until an echo arrives

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

* bump hbb_common: drop box_pk_of until something calls it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

* key exchange: say 0 on WebRTC, where no stream key applies

A WebRTC stream is encrypted by DTLS and `set_key_split` collapses to
`set_key` on it, which sets nothing but `peer_verified`. Both sides still
put 1 on the wire, the controlled peer in its signed identity and the
controller in its pick, and agreed on a version neither applied. That held
only because both fell into the same branch: the day one side splits keys
on WebRTC and the other does not, they would agree on 1, derive different
keys, and have no version mismatch to point at.

The controlled peer now advertises 0 on WebRTC and refuses a pick above
what it advertised rather than above the newest it speaks anywhere; the
controller picks 0 there. What is on the wire is what runs.

Also bumps hbb_common for the echo contract: the server tags every
message after the exchange, not the first alone, so dropping one frame at
a known position does not rid an attacker of the downgrade check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

* key exchange: verify the server's signed version before trusting it

bump hbb_common d9f519e: KeyExchange.signed_version

A server that signs its version sets bit 255 of the ephemeral key inside
the signed keys[0]. A client that sees that bit, or the field itself,
verifies sign("rdkx-ver" || key || version LE) under the server's signing
key before picking a version and refuses the exchange otherwise, so a
version lowered in the clear is caught at the handshake rather than one
frame pair later by the echo. A legacy server sends canonical keys and no
field and takes the unchanged path; the echo still checks its version.

Tests: version 1 keys match the server both ways; an advertisement
lowered in transit is refused at the first echo; legacy, signed-1 and
signed-3 servers each exchange application data; nine tamperings of the
signed version are refused before the client replies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* key exchange: comments say what the fields and functions are

bump hbb_common e3452ac: the same on the shared side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* key exchange: drop the advertisement echo

bump hbb_common 5194159: RendezvousMessage.kx_advertised and the check on
decrypted frames are gone. signed_version settles the version inside the
exchange, so the client arms nothing after it; the stub in the tests
stops tagging its frames and the test of a lowered advertisement goes,
the signed-version cases covering that refusal before any reply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* key exchange: verify KxParams, the server's signed structure

bump hbb_common e74a188: KeyExchange.signed_params and KxParams.

The client parses the signed bytes as KxParams and compares its fields
to the key it verified and the version it was shown, rather than
matching a fixed byte string, so a field added to KxParams later parses
past an older client. The tamper cases now include a payload signed as
the old byte string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* key exchange: require the KxParams signing prefix before parsing

bump hbb_common 26582e1: KX_PARAMS_DOMAIN.

Without it a signed IdPk, which the server signs with the same key,
parsed as a KxParams whose pk was the id and whose version was 0, so an
id registered with the bytes of a marked ephemeral key could stand in
for the params at version 0. Two tamper cases pin this: params signed
without the prefix, and a signed IdPk in their place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* key exchange: one call sets the negotiated key

bump hbb_common cbc8772: Stream::set_negotiated_key.

The rendezvous client, the controller and the controlled side each
branched on the picked version to choose between split and single keys.
They now hand the transcript to Stream, which makes that choice once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* Update hbb_common: refuse unknown key exchange versions, pin wire vectors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* client: prove the peer handshake picks its version and encrypts under it

The rendezvous exchange had tests against a stub server; the peer
handshake had none, so a controller that fell back to version 0 would
still connect, still report secured, and pass every test. These run
Client::secure_connection against a stub controlled peer and check
the pick it sees and the application data both ways: new peers pick 1,
a peer without versions gets 0, and a pick lowered in transit leaves
the two sides unable to read each other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* Update hbb_common: pin the subkeys for an advertisement above the pick

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* Update hbb_common to main, with #614 merged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* key exchange: name the picked version, say why the marker bit is safe

Review follow-ups with no change in behaviour: the rendezvous pick is
called picked, as in the peer handshake and KxTranscript; the marker
comment says X25519 ignores the bit and the bytes stay as signed; the
controlled side's refusal names the version it offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 17:30:22 +08:00
21pages b30f781fd6 fix: restrict VRAM stall detection to AMD SDK (#16319) 2026-09-23 14:20:42 +08:00
RustDesk 68a35e65f2 fix(unix): ignore SIGPIPE in native Flutter entry (#16322)
* fix(linux): ignore SIGPIPE in Flutter runner

Initialize SIGPIPE before entering the Rust core so writes to closed IPC peers return EPIPE instead of terminating the server. Add a native-entry regression test for the failure reported in #16297.

* test(linux): remove SIGPIPE runner test

* fix(flutter): initialize SIGPIPE in the Rust native entry

Share SIGPIPE initialization between the Linux and macOS native runners before core startup. Remove the Linux C++ initialization.

* fix: share SIGPIPE initialization with Sciter startup

Initialize SIGPIPE before global_init in core_main, shared by Flutter and Sciter on Linux and macOS.

* fix(flutter): keep SIGPIPE setup in the native FFI entry

Sciter already receives SIGPIPE initialization from the Rust runtime. Scope the missing startup initialization to native Flutter runners on Linux and macOS.
2026-09-23 14:05:15 +08:00
Mariano Abad 7c21b4569c test(drm): bound the excused shapes at their worst angle, not only on average (#16320)
Both of the review follow-ups on rustdesk#16242.

One comment still said alias was 0.11 px worse than master. That was the
number before the directional rule; it is 0.51.

And the exemption for help and alias was bounded only on the average over the
angles master handles. The worst single angle is the larger number and the one
that would be felt - help is 8.00 px under master at 270 and 21.54 here - so
leaving it unbounded let the exemption widen quietly. Both directions are
bounded now, at the measured values: alias 0.51 mean and 8.94 at 90, help 4.73
mean and 13.54 at 270.

Tests only.
2026-09-23 10:39:10 +08:00
Ricardo Simões c6a358afbe Update lang.rs - Splits ambiguous pt into pt-pt and pt-br (#16155)
* Update lang.rs - Splits ambiguous pt into pt-pt and pt-br

Splits the ambiguous "Português" entry into two explicit options: Português (Portugal) [pt-pt] and Português (Brasil) [pt-br]. The pt_PT.rs translation file already existed in the repo (fully translated, key-complete) but was never wired into lang.rs. Kept the old "pt" and "br" locale codes mapped to ptbr::T for backward compatibility with existing user configs.

* Preserve pt-PT/pt-BR region in automatic locale resolution

Preserve pt-PT/pt-BR region in automatic locale resolution

* Fix pt-pt detection: check region suffix instead of redundant "pt" substring

Fix pt-pt detection: check region suffix instead of redundant "pt" substring

* Normalize legacy pt/br saved lang option to pt-br

Normalize legacy pt/br saved lang option to pt-br

* Normalize legacy pt/br lang option on Web client

* Add regression tests for pt-pt/pt-br locale resolution

* Follow CLDR inheritance for Portuguese regional locales (pt-AO, pt-MZ, etc.)
2026-09-22 16:31:23 +08:00
Dmytro 458ffe44c6 Update Ukrainian README (#16309)
* Start Ukrainian README terminology update

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>

* Synchronize Ukrainian README with current documentation

Update content, terminology, build instructions, badges, and project structure to match the current English README.

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>

* Polish Ukrainian README dependency wording

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>

* Clarify Docker build argument placement

Address review feedback by explaining where optional build arguments should be appended to the docker run command.

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>

---------

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>
2026-09-22 16:12:11 +08:00
21pages 2e7255f083 fix: suppress heartbeat HTTP retry and TCP proxy logs (#16273)
* fix: suppress heartbeat HTTP retry and TCP proxy logs

Signed-off-by: 21pages <sunboeasy@gmail.com>

* refactor: move API log interval to common module

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-22 16:04:22 +08:00
fufesouandMariano Abad 12f61b1fda Fix/drm rotated cursor (#16242)
* fix(drm): turn the cursor by the output's real angle, and guess its hotspot upright

Two things were wrong with a rotated output's cursor, and the maintainer's
physical test found both.

The bare-metal hotspot is a guess - the top-left of the sprite's opaque box,
since HOTSPOT_X/Y only exist on para-virtualised drivers - and it was guessed on
the sprite as scanned out, then mapped as if it were a point on it. A turned
arrow's tip is never in that corner: at 90 the guess is off by the arrow's width
along x, small enough to pass as fine, and at 270 by its height along y, which
is what was noticed. Measured on an i915 box with a 64x64 Adwaita arrow, hotspot
against tip: +11,0 at 90 and 0,+18 at 270 before, 0,0 at every angle after. A
guessed hotspot is now guessed on the upright sprite, in the consumer, and the
wire says whether the driver measured it so a real one still maps as a point.

And 180 was clamped to 0 for the whole session because a hardware-rotated
primary plane scans out upright. That holds for the frame and not for the
cursor: the compositor pre-rotates the sprite in software either way, so it
arrived upside down. The frame keeps the clamp; the cursor gets the real angle.

(cherry picked from commit 6a73100b4783fbf9c8f43e0dc7651b7568db9ea9)

* test(drm): drive the cursor path, and drop the field nothing reads

The two tests called the helpers, so nothing pinned the production
decision: restore master's `t == 90 || t == 270` in deliver_drm_cursor
and both still pass while the 180 sprite goes back to upside down. The
new test drives deliver_drm_cursor and reads back what it published,
and it fails under exactly that mutation.

The other way to reintroduce the bug was to point the receive thread at
`Shared::transform` again. That field had no readers left once both
gates moved to `cursor_transform` - it was stored and never loaded - so
it is gone, and the doc above TRANSFORM_PENDING now names the field
that actually holds a racing cursor back.

Also four comments that said more than the code does: the sprite is
treated as pre-rotated at every angle because wl_output cannot say
otherwise, not because the compositor provably always does it; the old
flow was off at every angle, not just at one of 90/270; the 180
symptom had its own cause, the clamp, rather than all of it coming out
of the hotspot guess; and the test arrow's opaque box is its left three
columns, not the whole bitmap.

* docs(drm): say what the 180 measurement actually showed

The loop asserts the old mapping was wrong at 180 too, and that is true
of the mapping, but master never ran it there: the clamp sent 180 down
the untouched branch, so guess and sprite turned together and the
hotspot landed on the tip. The bench measured exactly that - (0,0) at
180, with only the sprite upside down. The comment said the old flow was
wrong at every angle without that distinction, which is the kind of gap
between a claim and a measurement worth closing before someone else
finds it.

* fix(drm): centre the guessed hotspot of an I-beam lying either way

The bounding-box guess only centred TALL shapes, so a HORIZONTAL I-beam was
handed the top-left corner of its box. Adwaita's `vertical-text` is exactly that
shape: at 24 px its opaque box is 20x9 and the theme's own hotspot is (12, 11),
its centre. The corner guess lands 11.2 px away; the centre lands 1.4 px away.

It matters more since this branch turns the sprite upright before guessing. Such
a cursor used to reach a quarter-turned output as a TALL bitmap, where the
tall-only rule centred it by accident; guessing on the upright bitmap - correct in
itself, and what fixes arrows at every angle - is what exposed the asymmetry.

Found by fufesou at pixel level on rustdesk#16242, and reproduced here against the
installed theme before changing anything: same declared hotspot, same bounding
box, same two errors. Then checked across ALL 35 shapes of that theme at 24 px:
making the aspect test symmetric moves exactly one of them, `vertical-text`, from
11.2 px to 1.4 px, and leaves the other 34 byte-identical.

The old flow is not the fix. It was right for this one shape and wrong for arrows
at every angle, which is what `a_guessed_hotspot_is_guessed_on_the_upright_sprite`
pins; the fix belongs in the guess, not in the order of operations.

The regression test that missed this used only `arrow()`, as fufesou pointed out.
Three things the first version of the replacement got wrong, all of them in what
it CLAIMED rather than in the fix:

- Its expected point was the centre of the BITMAP, which equals the centre of the
  opaque box only because the fixtures filled their bitmaps symmetrically. Both
  I-beam fixtures are now off-centre in their buffers, the way a sprite sits in a
  64x64 hardware cursor, and the test asserts up front that the two centres differ
  so the fixture cannot quietly stop discriminating.
- It called the arrow's box "elongated by less than the factor of two", when 3x6
  IS the factor of two and the arrow keeps its tip only because the comparison is
  strict. That makes the arrow the control for one side of the threshold, and it
  is now described as such.
- It said the case was driven "through the whole delivery path" while chaining the
  helpers itself. It now calls `deliver_drm_cursor` and reads the published
  hotspot back out of DRM_CURSOR, at 0, 90, 180 and 270, feeding it what the
  producer really sends for a guessed hotspot: the producer's own guess, made on
  the sprite as scanned out.

And the threshold was pinned from one side only. A third fixture carries the real
2.22:1 aspect of the shape that was wrong, so a stricter rule cannot pass on 6:1
bars while silently un-centring the actual cursor.

Mutations, each restored byte-for-byte afterwards: back to tall-only, the bitmap
centre instead of the box centre, the threshold loosened to one and tightened to
three. All four fail the test.

* build(drm): point the pin at the synced mirror and drop the helper fallback

The mirror is synced, so the pin moves from 5da68a3a (libdrmtap 0.5.4) to
49b204f (0.5.6), which is what rustdesk-org/libdrmtap now carries.

-Dhelper=disabled, as the maintainer asked on #16242. rustdesk never uses the
privileged helper: every drmtap_open lives in src/ipc/drm.rs, i.e. the root
service, which already holds CAP_SYS_ADMIN, and the unprivileged side opens a
render node instead. What the library carries without the option is a fallback
that walks six hardcoded paths, two of them under /usr/local, and execs the
first one that passes access(X_OK) -- no check of its owner, no check of its
mode -- from inside the ROOT process. The option compiles that path out.

Asserted on the artifact as well as passed as a flag, for the same reason the
EGL check is: a flag cannot notice a stale build-pkg or an object substituted
by hand, and meson accepts an unknown -D silently on versions predating the
option. Measured on the produced .so: socketpair 4 -> 0, the helper search
paths 3 -> 0, and nm -D shows no fork, execl or socketpair import. The check
fails as it should when pointed at a .so built with the helper.

* fix(drm): use the library's hotspot provenance, and keep legacy IPC meaning

Answers the two blockers in the maintainer's review of 9acb37a9d, plus the
three he marked should-fix.

hot_measured no longer guesses. The pinned libdrmtap 0.5.6 exports
drmtap_cursor_hotspot_valid, and the old test -- hot_x != 0 || hot_y != 0 --
cannot separate the two things a (0, 0) hotspot means: no HOTSPOT_X/Y on the
plane, or a driver whose answer really is the corner. The symbol is resolved
OPTIONALLY, the way drmtap_render_node and drmtap_list_devices already are,
with the same version-aware warning when a library that reports 0.5.6 or newer
does not carry it. An older deployed .so keeps the previous behaviour instead
of the ABI floor moving under it and refusing to load.

The IPC default is corrected. Data is JSON over a unix socket between two
processes upgraded separately, so an old root service can be streaming to a
freshly started --server and its DrmCursor has no hot_measured at all. Read as
bool::default() the new consumer discards a hotspot the producer measured and
re-infers one from the upright bitmap, moving the cursor on a rotated display
for the length of the upgrade. The default is now true, which is not a claim
that the value was measured: it means "no provenance available, so behave as
the old protocol did". A new producer that wants re-inference sends false
explicitly, which serializes, so new-to-new is unchanged.

hot_measured is folded into the cursor id. It is not metadata about the cursor,
it selects what the consumer does with it, so a sample whose pixels and (0, 0)
hotspot are unchanged but whose provenance flipped is a different cursor and
must not be deduped away by the producer.

build.py reconfigures an existing meson build dir with -Dhelper=disabled
instead of only configuring a fresh one; a directory from before the option
kept its old configuration and the artifact assertion then failed the build.

And the drm CI job now runs the drm-gated tests. It built the feature without
ever executing them, so every cursor rotation and hotspot test had no
continuous coverage. A cargo test filter that matches nothing exits 0, so the
step also asserts it actually ran some.

Verified by mutation, each caught by its own test and no other: ignoring the
library's answer fails the two provenance tests; the serde default back to
false fails the legacy-deserialization test; dropping hot_measured from the id
fails the identity test.

* feat(drm): say once where the cursor hotspot came from

The provenance decision was unverifiable anywhere but a unit test: nothing on
a running box distinguishes the three states, so a deploy proved nothing. One
info line does, and with it all three are measured -- Some(false) on i915,
None with a 0.5.4 library swapped in behind the soname, and Some(true) via a
C probe on virtio-gpu, where no rustdesk build runs.

Once per state, not once per sample. A cursor is read on every capture
iteration and its provenance changes approximately never; a per-frame log line
here is the same defect libdrmtap already shipped once. Measured on Sigma-26:
one line across a whole session. The should-we-log decision is its own
function so it cannot quietly become per-sample again.

* test(drm): drive a legacy cursor message from the wire, not from a literal

The upgrade window is the one thing in this round that could not be staged
with two live processes: a --server from one build does not run inside the
other's tree, so the old service respawns it and it dies with exit 127. Tried
on two boxes, with two files swapped and with the whole tree.

This is the honest form of that test and it runs in CI rather than once on a
desk. It takes the exact bytes an old producer puts on the socket -- no
hot_measured key at all, adjacently tagged as Data really is -- deserializes
them the way the receive loop does, drives the real delivery path, and reads
back what was published. The transform is non-zero on purpose: at 0 the path
never consults hot_measured, so a test there could not tell the branches
apart, and the fixture is asserted to make mapping and re-inferring disagree
before anything is concluded from them.

Verified by mutation: restoring #[serde(default)] -- the reported bug -- fails
it, and so does a delivery path that ignores hot_measured.

* fix(drm): run the tests that matter in ci, and keep provenance state per stream

Answers the review of 7dc6817 plus the CI failure fufesou reported on 401c2f7.

The drm test step was broken three ways and none of them showed up as a red
test: it reused the production $FEATURES, so the test binary failed to LINK
(undefined reference to fcntl64 from hwcodec) and the tests never ran; it
summed the counts with bc, which the ubuntu18.04 container does not install;
and `cargo test --lib` at the repo root builds the ROOT package only, so the
provenance tests in libs/scrap were never selected -- the "at least one test
ran" guard passed on the root filters and hid it. Now: --features drm rather
than the production set, awk instead of bc, and an explicit `-p scrap` run
with the same assertion. Locally 36, 9 and 5 tests, and a bogus filter still
fails the step.

Provenance logging state was process-global on both sides. DRM runs one
reader per captured display, and two outputs with different but STABLE
provenance -- one publishing HOTSPOT_X/Y and one not, which is an ordinary
multi-GPU host -- would overwrite each other and make every message look like
a change, i.e. exactly the per-frame logging the one-shot was written to
avoid. The producer's state moves into DrmReader, the consumer's is keyed by
display. The hidden-cursor sentinel is no longer recorded: it carries
hot_measured=false because it has no hotspot, so counting it turned every
hide and show into a provenance transition.

The 180 test is renamed to say what it pins -- i915 advertising rotate-180
with mutter -- and its comment says plainly that it is not a general contract,
that Plasma is still wrong there, and that changing it means re-measuring.

Verified by mutation: restoring the global slot fails the new per-display
test, and so does recording the hidden sentinel.

* fix(drm): centre the guessed hotspot on the axes a cursor mirrors about

The guess put the click point at the top-left corner of the opaque box unless
the box was more than twice as long as it is wide. That corner is where an
arrow's tip is, because cursors are drawn pointing up and to the left, but a
shape that mirrors onto itself has no tip to point with: a crosshair, a
two-headed resize arrow, a cell marker. Those got a corner where the theme puts
the middle, and the error is about half the glyph.

So: centre the axis the shape is symmetric about, and keep the corner
everywhere else. Measured against the hotspots the theme declares, which is the
value a compositor programs into HOTSPOT_X/Y where the property exists:

  adwaita-icon-theme 50, 35 shapes at six nominal sizes, 210 cases
    mean error 0.43 -> 0.27 of the cursor size, 17 shapes improve, none worse
  adwaita-icon-theme 46, 34 shapes at five sizes, 170 more cases
    better at every size, none worse

The threshold is not fitted: from 90 to 98 percent agreement nothing regresses,
and below 90 three shapes do.

The corpus is carried as fixtures so the expected value comes from the theme
author rather than from our own heuristic, and two tests read it: the published
hotspot must not move when the output turns, with the old flow's 15 px average
movement as the control, and the guess must stay inside the measured bounds
with the old rule frozen as the baseline.

The elongation test stays ahead of the mirror test so an elongated shape that
is not symmetric still keeps its centre.

* fix(drm): put a directional cursor's hotspot on the edge it resizes

The mirror rule centred the axes a shape is symmetric about and left the
other axis on the top-left corner. That is right for an arrow, whose tip is
that corner, and wrong for the resize cursors: those are a pointer plus the
marker of the edge being resized, the theme puts the click point on the
marker, and the marker is at whichever end the cursor names. Always taking
the corner therefore had to be wrong for one of every mirrored pair, since
e-resize and w-resize are the same picture reflected.

So an axis is read as directional when the perpendicular axis mirrors, which
is the n/e/s/w family, or when the shape mirrors about a diagonal, which is
the corner family. A directional axis takes the heavier end.

A diagonal reflection only lines up inside a square, and these boxes are not
square, so where the box sits in that square decides the answer: ne-resize
scores 0.62 anchored one way and 0.98 the other, and nw-resize is the reverse.
Both anchorings are tried and the better answer wins.

Measured against MASTER now, not against this PR's previous revision, which
is what the maintainer asked for. Over the angles master handles (0, 90, 270),
adwaita-icon-theme 50 at 24 px:

  mean error per shape   12.08 px -> 4.20
  (shape, angle) cases   83 better, 18 the same, 4 worse of 105
  shapes worse than master   help (+4.73) and alias (+0.51)

Both of those are semantic: the theme puts help's click point on the dot of
its question mark. The same holds at 48 px and on adwaita-icon-theme 46,
where ne-resize is also 1.34 px worse because its glyph is drawn differently
there.

The test baseline was wrong and is fixed here: it froze this PR's earlier
symmetric aspect test rather than master's tall-only one, so it could only
show that the new rule does not regress the old rule. It now freezes master's
guess AND master's delivery, and names the two shapes it excuses with the
measured size of each.

The mirror threshold moves from 95 to 88, the middle of the 86 to 91 interval
that regresses nothing on either theme version.

---------

Co-authored-by: Mariano Abad <weimaraner@gmail.com>
2026-09-22 15:18:10 +08:00
rustdesk 6a0eb9a1fd fix(terminal): preserve trackpad scrolling in alternate screen
Prevent the inner terminal scrollable from accepting user scrolling in the alternate buffer, so xterm's existing outer handler receives trackpad gestures after main-screen history.

Keep wheel encoding and drag reporting unchanged. Cover trackpad forwarding, subsequent drag coordinates, arrow fallback, and return to local scrollback.
2026-09-22 11:45:05 +08:00
rustdeskandClaude Fable 5.1 763d4eeb05 bump hbb_common: tungstenite 0.29 forks, read buffer grown as bytes arrive
hbb_common 7dc56d6..d9895ff, one commit: tokio-tungstenite and tungstenite
up from 0.26 to rustdesk-org's rustdesk-patches-0.29 branches. The
tungstenite fork grows the read buffer a read_buffer_size chunk at a time
as bytes arrive, where upstream reserved the whole length a frame header
declared. It is pinned here at the workspace root, the one place cargo
reads [patch], so hbb_common's copy and tokio-tungstenite's resolve to
the same one. The lockfile moves only those two crates and drops utf-8,
which 0.29 no longer pulls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-21 17:58:35 +08:00
fufesou a5d4ef97e8 fix(ws): reject text frames after encryption is enabled (#16295)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-20 18:21:15 +08:00
RustDeskandClaude Fable 5.1 dd9b21cf86 server: hold an unauthenticated connection to a small message (#16254)
* server: hold an unauthenticated connection to a small message

Until a peer authorizes it sends only a public key, a login request, a test delay
and a close reason, none of them large. Nothing said so: a frame header could
declare up to whatever the transport allowed, 1 GiB on TCP and WebRTC, and a
connection holds its place for up to LOGIN_GRACE before it has to authorize. With
MAX_UNAUTHORIZED_CONNS places to fill, that is 64 GiB of header-declared payload
one peer could make us hold - or, on WebSocket, 1 GiB bought outright with a few
hundred bytes of frame headers, because tungstenite reserves a frame's declared
payload as soon as it passes max_frame_size.

The cap goes on in create_tcp_connection, before the identity handshake, so that
read is bounded too, and comes off once the login is settled. It comes off before
connect_port_forward_if_needed rather than beside the rest of authorization: a
multiplexed tunnel narrows the same knob again for its own framing and has to have
the last word.

128 KiB is several times the largest login request anyone sends - a long hostname,
an os_login, an avatar URL, a file-transfer path - and is also the read buffer
tungstenite allocates per WebSocket connection whatever we do, so on that transport
the bound costs nothing beyond a floor already paid. A server hands that avatar
out as a URL; only a custom client that inlines an image into the avatar option
instead can reach the bound at all. Together with
MAX_UNAUTHORIZED_CONNS it holds every unauthorized connection to 8 MiB. Redis
answered this same shape in CVE-2021-32675 with 16 KiB, tighter because a
per-message bound is the only one it has; here the connection count is the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

* server: test that the unauthenticated cap is on before the handshake reads

hbb_common covers each transport's cap; nothing covered where the connection
layer puts it. A header one byte over MAX_UNAUTHORIZED_MESSAGE, written to a
connection stalled in the identity handshake, has to end the handshake on the
header alone and release the connection's place: the test fails with the cap
moved past the handshake or dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

* client: hold the clipboard until this round's login is accepted

The clipboard listener is one per process, started by the first session to
log in, and its broadcast went to every session - one whose login was still
waiting on a password, 2FA or the peer's consent included. What the user
copied meanwhile went to a machine that had not admitted them; the peer
drops it unread before authorization, but it is in that peer's hands.

The check goes on the round, not the session: a session-level one reads a
state and later a sender that a reconnect can have swapped in between, so a
broadcast that passed for the round before could still queue on the next.
Remote is the round - its queue, and is_connected set once its own PeerInfo
is in - so a Clipboard or MultiClipboards that reaches handle_msg_from_ui
before then is dropped there, on the line before it would go out. What the
login itself sends through the same queue, Auth2FA among it, goes as before.

The unauthenticated cap on the other side is how this came up: a clipboard
over 128 KiB ended such a login with "Reset by the peer". The small case had
always gone through quietly.

A test drives a round's Remote over a loopback pair before any login: a
clipboard does not reach the far end, a 2FA code does, and once the round is
connected the clipboard does too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:26:42 +08:00
Alex Rijckaert 97811acbdd Update Dutch translation (#16284) 2026-09-19 16:05:18 +08:00
RustDeskandClaude Fable 5.1 7a367f7cc6 bump webrtc fork: one IPv6 host candidate per interface and prefix (#16263)
* bump webrtc fork: one IPv6 host candidate per interface and prefix

ICE gathered every IPv6 address on every interface. Beside the temporary
address that privacy extensions rotate, a prefix usually carries a stable
one, often derived from the MAC, that the OS never picks as a source: a host
candidate for it hands the peer an identifier that outlives every rotation
and that nothing else this machine sends out ever shows.

RFC 8445 §5.1.1.1 has the trackable addresses of an interface and prefix
left out once a privacy one is gathered. There is no portable way to tell
the two apart, so the fork's `local_interfaces` stands in for that rule
with a best-effort approximation: of an interface's addresses in one
prefix, it asks the OS which one it sends from - a UDP `connect` inside the
group's own prefix, nothing sent, nothing outside this machine's own
prefixes involved - and if the answer is one of them, keeps that one alone.
If the answer is none of them, the whole group is kept, as it was: the
probe is bound to no interface, so where Ethernet and Wi-Fi share a LAN the
route picks one of them and the answer for the other is an address it does
not hold, and the enumeration order would be no better a guess - on macOS
its first address is the stable one. Every other interface and prefix keeps
its address, a VPN's unique-local one among them. Two static addresses in
one prefix keep one, the recorded price of the stand-in; the interface a
shared prefix's route bypasses keeps both of its addresses, a gap the
stand-in leaves open rather than a regression.

The Windows enumeration, which named every adapter "" with no mask, now
carries the adapter's name and on-link prefix, without which Ethernet and
Wi-Fi on one LAN would have been a single group. That reaches IPv4 too:
its addresses carry the adapter's name and mask where they were "" at /32,
so the candidates gathered are the same but `interface_filter` sees the
real names. hbb_common is untouched: its fe80::/10 filter still applies to
what the fork keeps.

Two more fork commits ride along, found by the same review. SCTP never
reported a DATA chunk received again: `handle_data` asks `can_push` before
`push`, and `push` was where a duplicate was noted, so the no-cwnd sender's
reordering window, which widens on reported duplicates, never heard of one
from another of these endpoints; and the SACK, its gap blocks and now its
duplicates unbounded, could outgrow the MTU the DATA chunks keep to under a
thousand chunks in flight with holes among them. Duplicates are now listed
and SACKed at once (RFC 9260 §6.2), and the SACK reports the lowest gap
blocks that fit (§6.7). And the Windows adapter struct, split into nested
parts, read `Ipv6IfIndex` eight bytes late on x64 - only into a scope id
that `Interface::convert` drops, so nothing gathered wrongly, but the field
an interface-bound probe would need; it is flat now, with a compile-time
check written for Rust 1.75, the version this crate builds with -
`offset_of!` would have wanted 1.77.

rustdesk-org/webrtc 80d5a20..49c89bd8, six commits: the heuristic, the
fail-open it grew in review, its comments brought in line with that, the
SCTP duplicates and SACK bound, the flat Windows adapter struct, and its
check made to build on 1.75.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* pin hbb_common to main 7a5ad52

0eb1759..7a5ad52, six commits: the message cap's tests on WebSocket and
WebRTC with the fragmented WebSocket message bounded too (e999dce,
f0f1548); hyper_util's debug logs out of the default filters (#608); and
`new_direct_udp_for_unverified`, the controller's UDP socket on the
resolver's preferred address for the rendezvous server, with a second for
the lookup and without the TCP connection `test_target` opens and drops to
prove it - while `new_direct_udp_for`, `new_udp_for` and `rebind_udp_for`,
which the controlled side's punch reply and its registration live on, keep
that proof (f688d41, 0bc8336, 7a5ad52).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* ipv6 punch: ask the kernel for the route without resolving a name first

`test_ipv6` is awaited on the connection path by both sides of a punch, and
before it looked for a public IPv6 address in the background it resolved
the STUN hosts' names inline - racing the four, so that one resolver that
hangs would not decide. It could still: `select_ok` returns the first
success or the last failure, so with no resolver answering the probe waits
for the slowest lookup to give up, as long as the system resolver takes,
once a minute, on the first connection of that minute.

The name was never needed. `connect` on a UDP socket sends nothing; it has
the kernel pick a route and a source address for the destination, and any
global address serves, so the probe now names one - the one libwebrtc's
QueryDefaultLocalAddress asks for - and touches no network at all: a bind,
a connect, a local_addr. A machine without an IPv6 route learns so from the
connect's error, at once, as before.

Two smaller things beside it. The minute's gate read the timestamp under
one lock and set it under another, so two connections arriving together
both found it over and both probed; it is one critical section now. And the
background STUN probe, bounded so far by the STUN client's own ten seconds
and the resolver's, has a deadline of its own, five seconds: a probe that
outlived the minute could write an earlier network's address over a later
probe's.

Test: the route probe completes within a second, an address found or not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

* controller: the NAT test takes the resolver's address for the server

`new_direct_udp_for` learned which address to send the UDP NAT test to by
opening a TCP connection to the rendezvous server and dropping it - a
handshake, one round trip, on every connection the controller starts,
right before `_start_inner` opens the connection it keeps to the same
host. The NAT test now takes the resolver's preferred address,
`new_direct_udp_for_unverified`, which spends no round trip proving it and
gives its lookup a second: an address the server does not answer on costs
this connection its UDP punch, which the TCP punch and the relay cover, as
they do whenever the test finds no port. The controlled side's punch reply
keeps `new_direct_udp_for` and its proof - a reply sent to such an address
is a device online and unreachable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:20:23 +08:00
RustDeskandClaude Opus 5 ea04f04f9d add hide-elevate-button-in-accept-window (#16271)
A portable client handed to standard users offers them "Accept and Elevate",
which they have no credentials to complete. The builtin option takes that
button out of the accept window and leaves elevation to the controlling
side's "Request Elevation" during the session.

https://github.com/rustdesk/rustdesk-server-pro/discussions/1016


Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:54:11 +08:00
Dmytro ef00ed78a7 Update missing Ukrainian UI translations (#16272)
* Update Ukrainian translations

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>

* Complete Ukrainian voice call translation

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>

---------

Signed-off-by: Dmytro <dmitriy.gaponuk@gmail.com>
2026-09-18 16:27:34 +08:00
5278fcab68 fix(cursor): correct native cursor sizing and validate received images (#16213)
* fix(flutter): shrink the unzoomed remote cursor by DPR on macOS and Linux

With "Zoom cursor" off in Adaptive or Custom view, the remote cursor
bitmap was registered at scale 1.0. NSCursor and GdkCursor treat the
bitmap size as logical pixels, so on a HiDPI controller the cursor was
drawn DPR times larger than in Original view (which already passes
1/DPR) and than on Windows (whose cursor path is in physical pixels).
A HiDPI remote such as KDE Wayland sends a 48-64 px bitmap, which then
showed up 3-4x too big on a Retina Mac.

Scale the bitmap by 1/DPR in that case, and scale the Flutter-painted
cursor used while the peer moves the mouse the same way so its size
does not jump. The new branch is an identity at DPR 1 and the Windows
paths are untouched.

Fixes https://github.com/rustdesk/rustdesk/discussions/15363

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP

* fix(flutter): check the cursor height against the min cursor size

`_checkUpdateScale` computed the scaled height from `width`, so the
min-size clamp never looked at the height.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP

* fix(flutter): keep the painted cursor hotspot in place when zoom cursor is off

`CursorPaint` subtracted the hotspot in remote pixels and then scaled it
by the canvas scale, but drew the image at scale 1.0, so the hotspot
landed hotx * (1 - scale) logical pixels away from the remote cursor
position. Cursors with a centered hotspot (I-beam, crosshair) were off
by up to half their size in Adaptive view. Subtract the hotspot after
scaling the position instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP

* fix(flutter): read the live DPR and clamp the painted cursor like the native one

CanvasModel caches devicePixelRatio and only refreshes it when the view
style changes, so after the window moves to a monitor with a different
DPR the unzoomed cursor kept the previous monitor's scale. Read it from
MediaQuery instead, which also rebuilds the cursor when it changes.

The native path clamps the scaled bitmap to kMinCursorSize; apply the
same clamp to the painted cursor so a small cursor does not change size
when the peer moves the mouse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP

* build(cursor): declare existing Zstd dependency for bounded decoding

* fix(cursor): validate and bound received cursor images

* fix(cursor): preserve thin cursor sizes when scaling

* fix(cursor): limit view changes to native cursor sizing

* fix(cursor): apply long-edge minimum to Web cursor sizing

* fix: preserve Linux cursor alpha and match Windows Custom scale

* fix(cursor): keep scaled buffers and raster dimensions in sync

* fix(cursor): preserve Windows peer alpha when resizing

* fix(cursor): preserve mixed alpha during downsampling

* comments

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(cursor): match desktop cursor size to peer display density

* refactor(cursor): clarify desktop and web scale branches

* fix(cursor): correct adaptive display scaling and fixed cursor size

* fix(cursor): apply all-display adaptive density on every desktop

* fix(cursor): normalize unzoomed all-display cursor density

* fix(cursor): synchronize original view on DPR changes

* fix(cursor): match original zoom to the active renderer

* fix: align Wayland software scrolling with input coordinates

* fix: preserve scroll offsets when switching scrolling modes

* fix: correct cursor size and pointer mapping with custom scale

Apply the hovered Linux display density to Custom cursor zoom in All Displays. Refresh scroll fractions after layout so changing the Custom percentage uses current scrollbar extents and detached controllers.

Validated with macOS and Windows component tests, formatting, and static analysis.

* fix(linux): pad tall native cursors to prevent clipping

* fix(cursor): preserve macOS point size in unzoomed views

* fix(linux): pad rectangular cursors to square canvases

* fix(cursor): divide dpr on Linux -> macOS

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: cursor size test

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(cursor): cursor size of controlled side macOS

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(cursor): limit received cursor allocations

* test(cursor): retain reverse raster transition coverage

* fix(cursor): bound compressed cursor input

* fix(cursor): restrict the scaled size of the cursor

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(cursor): align hotspots with resized raster dimensions

Calculate each hotspot axis from the actual raster-to-source ratio.
Update existing boundary tests and run cursor tests in Flutter CI.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(cursor): align Sciter hotspots with raster dimensions

Calculate native and overlay hotspots from the final raster size.
Preserve input coordinate scaling and cursor refresh order.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(cursor): use `1.0` instead of `1.0/dpr` on Linux -> macOS, Zoom off, Scale adaptive

The mouse cursor currently appears somewhat large.
However, this is difficult to adjust because the cursor size
is fixed while the window size varies; its relative size
depends on the specific desktop environment.

We can modify it if users actually provide feedback.
Ideally, we should check the "Zoom cursor" option.

Further adjustments may also be needed later based on cursor density.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: rustdesk <info@rustdesk.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:43:07 +08:00
memory_clear 53ec874c39 Translate terminal clipboard tips to Chinese (#16266) 2026-09-18 11:45:10 +08:00
Maison da Silva 04974591b8 Refine Portuguese translations for user options (#16264)
Updated Portuguese translations for clarity and conciseness.
2026-09-18 11:05:52 +08:00
Abdullah Hüseyin Efe 4da167517e Turkish: translate the three empty entries (#16261)
terminal-clipboard-write-tip, "Allow terminal apps to copy to clipboard" and the
voice-call hint were empty, so Turkish users saw the English text.
2026-09-18 09:59:45 +08:00
RustDeskandClaude Fable 5.1 d4ac2c07b7 temporary password: rotate when a peer is let in, not when it leaves (#16080)
The one-time password was regenerated after the connection loop exited,
so a remote desktop session kept it valid for hours and a port-forward
tunnel for as long as its mapping lived. It now rotates the moment a
connection becomes authorized. Reconnects and windows opened from a
live session are unaffected: they log in on the password the session
remembers, for 30 seconds past its last activity.


Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 09:15:44 +08:00
RustDeskandClaude Fable 5.1 0b3a1ddd80 rendezvous: cap unauthenticated punches in flight (#16256)
A PunchHole needs no authentication: anyone who knows this id can ask hbbs
to have us open a punch, and each one hbbs sends is spawned without a limit.
Every punch pays per request. punch_udp_hole binds a socket of its own and
start_ipv6 another; the TCP punch opens a listener on the ephemeral port of
its own connection to hbbs, with a punch of its own in flight beside it -
`reuse` sets the socket flags, it shares no fd - and the LAN listen a
FetchLocalAddr opens is that listener again. All of them then wait up to
CONNECT_TIMEOUT for the peer, so a stream of requests holds as many sockets
as it likes for as long as it keeps sending.

Each takes a place in one of two pools of 32 - UDP over v4 and v6 in one,
the TCP punch and the LAN listen in the other - and gives it back the
moment the peer's session is up - the KCP accept, the TCP stream in hand -
or when the wait ends without one: a place stands for a socket waiting for
its peer and nothing past it, and from the session on the connection layer's
own limits apply, the same handoff the WebRTC answerer's slot makes at its
open data channel. At the limit an arrival is declined rather than an older
punch cut short, since the places turn over on their own, within
CONNECT_TIMEOUT and the few seconds the punch phases add to it, and cutting
one short would drop a socket that may be a moment from carrying a session. Two pools rather than one, so that neither
transport pays for the other's crowd: a connection costs a place in each,
the controller's preferred request punching UDP and its TCP fallback
request punching TCP, and the transports that lose the race hold theirs for
the whole wait, so one pool of 32 would be about ten connections setting up
at once and a crowd of UDP punches would decline a TCP punch that had
nothing to do with it. A place is taken only for a punch that will
be made: whether the v4 legs relay is decided first, since the relay branch
runs the whole session and a place held across it would let ordinary relay
traffic use the pool up. A declined LAN listen falls to the relay, as one
that fails for any other reason does.

Declined is the listen alone, never the reply. One PunchHoleSent carries the
WebRTC answer, the v6 address and the relay server along with the v4 punch,
and the controller sends one request for all of them and retries it three
times before it fails outright, so a reply withheld for want of a v4 place
would lose the transports that needed none. The reply goes out as before, on
a socket that then goes at once, resends and all - a declined request is not
worth a socket kept for its reply's sake, and the controller re-asks on its
own - and what the controller loses is its v4 attempt at a mapping that no
longer answers; its others go on.

A request that carries a v6 address costs two places, and the v4 one is
taken first: the v6 punch starts before the v4 one does, and taken in that
order the last place would go to the transport the peer may have no route
for and leave the one it can count on with none. A declined v6 punch leaves
the v4 path to carry the connection, as it already does wherever this
machine has no public IPv6 address. Each kind logs at most one line a
minute, carrying the number of requests it stands for, since the peer
decides how often it asks.

Tests: the places are the bound and come back on drop; a full UDP pool
leaves the TCP places alone; at the last place it
is the v6 punch that goes without; a declined UDP punch still sends its
PunchHoleSent, answer and v6 address intact, to a loopback stand-in for
hbbs; a listen whose peer never probes gives its place back.


Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 18:21:27 +08:00
fufesou 01e3917390 fix(ci): restore global configurations (#16257)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-17 16:59:00 +08:00
fufesou fd0fe592eb fix(rdev): macos, numpad flag, clear on text keys (#16253)
may fix #16227

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-17 12:04:32 +08:00
rustdeskandClaude Opus 5 bedf64c8d3 bump hbb_common: cap the message an unauthenticated peer can make us hold
Carries `Stream::set_max_packet_length` to all three transports, so a connection
can hold its peer to a small message until it has authenticated: TCP had the knob,
WebRTC's reassembly ceiling becomes a per-stream bound, and WebSocket reaches
tungstenite's config through a fork of v0.26.2 that exposes `set_config`, which
upstream still does not at 0.30.0.

Nothing calls it yet, so this changes no behaviour; the hook lands separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-17 12:00:37 +08:00
RustDeskandClaude Opus 5 4df7404a6c webrtc: cap concurrent answerer setups, pin the DTLS fingerprint binding with a test (#16225)
A WebRTC offer reaches the controlled side before any password or accept
prompt, and answering one builds a peer connection that binds a socket per
interface and runs ICE for up to CONNECT_TIMEOUT. A forged TCP punch reuses
the mediator's local port for one connect; a forged offer costs all of that,
and nothing bounded how many could be in flight at once. SESSIONS dedups by
offer fingerprint, which only stops replays of one offer.

spawn_webrtc_answerer now takes one of 16 slots before building the peer
connection. The wait for the data channel is bounded by CONNECT_TIMEOUT, and
what the slot stands for is the peer connection an unauthenticated offer had
this machine build, ICE, DTLS and SCTP: on an open channel it is given back
at once, and on a failed one it goes with the pc into the detached teardown
and comes back when that has finished. pc.close() has no timeout of its own,
so a slot freed where the task gives up would let a teardown that never
finished pile pcs up unbounded with the count reading zero; held, a stuck
teardown costs WebRTC capacity and the offers past the cap degrade to punch
and relay. Every failure before the pc exists releases the slot through the
guard's drop. From the open channel on the connection is one like any other,
and the connection layer bounds unauthenticated connections in number and in
time for every transport alike (#16237), a peer that stalls in the identity
handshake or after it included. So this guard stays inside the WebRTC path,
sized above what legitimate controllers reach at once in the seconds ICE
takes.

Past the cap the offer is declined with an empty answer, the reply the
controller already gets from a peer without WebRTC, so it carries on over
punch and relay. Declines log through the throttled-log macro. At the cap a
re-sent PunchHole for a live session also gets an empty answer rather than
the cached one, since the slot is taken before the cache is consulted; only
reachable at the cap, where degrading is the point.

The other change is regression coverage for the signed DTLS fingerprint
binding, which is unchanged. The controller's defence against a rendezvous or
relay that swaps SDP fingerprints is the fingerprint the controlled side signs
into IdPk and the comparison in secure_connection, and neither had a test.
The comparison moves into dtls_fingerprint_bound so it can have one, along
with decode_id_pk_dtls: the fingerprint round-trips under the signature,
another key or an edited payload yields nothing, empty never binds, and
decode_id_pk still sees the same id and pk.


Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:10:40 +08:00
RustDeskandClaude Opus 5 f4808b71d4 webrtc: encrypt the signalling legs to the rendezvous server, or send no WebRTC signalling (#16224)
Three TCP connections carry WebRTC signalling to hbbs in the clear: the
controller's punch connection, which carries the offer up and the answer and
both sides' ICE candidates through it, and on the controlled side the
short-lived connection that returns the answer and the one that trickles its
candidates. Candidates are every interface address of both machines, and the
controller is the side most often on a network it does not trust.

`secure_tcp` is fail-open by design: a server that answers the first message
with anything but a key exchange, or with nothing, leaves the stream in the
clear and the call returns Ok, which the paths from before such servers rely
on. That is not a channel WebRTC signalling may go out on.

So the four legs use `secure_tcp_required`: Ok only once the server's key
exchange has encrypted the stream, an error otherwise. WebSocket is treated
as `secure_tcp` treats it, as a transport encrypted already. On the controller an
error drops the offer, closes its peer connection through the guard and
reconnects, then punches without WebRTC on the fresh socket, with the legacy
condition applied to it as before; the failed exchange may have consumed a
message on the old one. On the controlled side an error abandons that WebRTC
attempt: the answer is not sent, or the candidates are not, and the
controller falls back to its other transports. A relay response carrying an
answer, which the symmetric-NAT and forced-relay branches send on a
connection of their own, keeps the relay and loses only the answer: the
response goes without it, on a fresh socket. Degrade to no WebRTC, never to
WebRTC signalling in the clear. `secure_tcp` itself is unchanged; the
exchange moves into `key_exchange`, which reports whether it happened.

A punch without an offer is unchanged: the legacy secure condition takes
this socket straight to the punch as before, and every other punch waits for
the UDP NAT test as before. The exchange does not replace that wait, it
spends part of the same budget, which now runs from before it: what is left
is waited out, and a probe that has already answered is taken at once.

Tests run a loopback stand-in for hbbs: a server that answers with another
message, or closes, is refused where `secure_tcp` would carry on in the
clear; a completed exchange is accepted and the stub decodes the reply with
its ephemeral key.


Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:53:58 +08:00
Vorschreibung 0ac2e7fb52 Avoid Xwayland scans on X11 sessions (#16232)
Desktop refresh runs repeatedly for active sessions, spawning 'psgrep'
procs and producing significant CPU load. Guard the Xwayland process
scan with the session protocol so X11 sessions skip work that cannot
contribute session information.

This preserves the existing Wayland discovery path while leaving X11
refresh behavior on its established display and xauth values.
2026-09-16 20:24:50 +08:00
RustDeskandClaude Fable 5.1 092a961b62 server: bound unauthenticated connections in number and in time (#16237)
A connection that never logs in costs whatever its transport costs, for as
long as it keeps itself alive: the only limit was the 30s idle timeout, which
any message resets. Nothing bounded how many such connections one machine
holds, on any transport. The shape sshd_config answers with LoginGraceTime
and MaxStartups.

Every connection is admitted among the unauthorized ones before its identity
handshake, in create_tcp_connection, and holds that place until it
authorizes or ends: the count of live places is the bound, not a ledger
beside the connections: the resource bound. One address may hold sixteen, a
quarter of the room; a further connection from it is refused before the
handshake. That share is a fairness cap against the cheapest flood, one host
with one address, not a security boundary: any pool of addresses passes it,
and the global limit is what holds. With 64 held in all, a further arrival
is refused too, and the oldest connection is told to go, unless one is on
its way out already: the handshake is raced against that eviction and ends
at once, and the session loop has it as a branch of its select, so the place
opens as soon as the connection has actually gone and not on a timer tick.
The newcomer is not let in on a place still occupied; the controller retries
on its own with backoff, and by then the place is free. At most one
connection is ever on its way out, so a burst of refused arrivals clears no
more room than a single one, and the retry that takes the freed place counts
against its address's share: one address turns out at most as many
connections as it may hold.

One deadline, from the moment the connection starts, a branch of the session
loop's select rather than a check on the TestDelay tick: a connection not
authorized after 180s is closed, however alive it keeps itself, a wrong
password, a pending 2FA, an accept prompt or an admin-terminal credential
prompt left unanswered. The controller reconnects on its own and the prompt
comes back. It closes with the Timeout reason the idle path uses, and that
path still ends a connection that says nothing for 30s. There is no shorter
deadline for the first login request: an admin-terminal controller shows
its credential prompt before sending one, and a peer that wanted to dodge
such a deadline would only have to send a login request, so it would bound
nothing.

The peer address is normalized with try_into_v4 before admission, the same
form Connection::start keys the whitelist on, so an IPv4 peer and its
IPv4-mapped IPv6 form are one address and not two shares.

The WebRTC answerer's slot keeps bounding peer connection setup up to the
open data channel; from there this covers it like every other transport.

Tests cover the registry and the live bound: an address over its share is
refused while others are admitted; at the limit the newcomer is refused, the
oldest is told to go, nobody else is while it is on its way out, and its
place frees only when it has; an address at the limit turns out no more
connections than its share and is then refused without evicting anyone; and
with the limit held by 64 connections stalled in the handshake, one more
arrival is refused while the oldest handshake ends at once and only then is
there a place again.


Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:47:24 +08:00
rustdeskandClaude Opus 5 4f56d6957a pin the webrtc crate to the fork that exposes max_binding_requests
The hbb_common bump before this one calls SettingEngine::set_ice_max_binding_requests(),
which the 0.13.0 release on crates.io does not have: only webrtc-util and webrtc-sctp
were patched to the fork, so the webrtc crate itself still came from the registry and
the build stopped at that call.

The three patches now point at the same fork revision, one commit past the one they
were on, which adds the setter. The webrtc crate depends on its siblings by path, so
patching it moves the rest of that workspace to the fork as well; the fork is upstream
v0.13.0 with changes to sctp and to this setter only, so those crates carry the same
code they did from the registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-16 14:18:03 +08:00