Commit Graph
11534 Commits
Author SHA1 Message Date
rustdeskandClaude Opus 5.5 0ba172278d test: keep the decomposed-grapheme and reconnect-cancel tests
Each catches a plausible wrong change that passes every kept test:
mapping the first scalar under a locked Ctrl turns é into \x05,
and a reconnect that does not fail pending reads leaves them hanging
until the 30 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R
2026-10-14 13:54:01 +08:00
rustdeskandClaude Opus 5.5 23909ded5b test: keep offline update and key-exchange downgrade rejections
Restore three tests that are the only coverage of their rejection class:

- offline update metadata rejects a signed artifact with an http or
  credential-bearing URL;
- offline verification rejects a renamed local file and a version that
  does not match the release;
- a key-exchange pick lowered in transit leaves both sides unable to
  decrypt, so a v0 fallback after a failed v1 decrypt cannot pass
  unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R
2026-10-14 13:54:01 +08:00
rustdeskandClaude Opus 5.5 3461b17c60 test: keep tests that pin security and user-visible behavior
Restore six tests that review showed are the only coverage of their
behavior:

- update metadata rejects an artifact file name with path components;
- ICE candidates from another session are dropped;
- KCP delivers every multi-segment frame queued before a close;
- set_files rejects an empty name in a multi-file transfer;
- a lopsided elongated cursor keeps its centre hotspot (#16242);
- toggling Show hidden during a pending same-path read issues a new read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R
2026-10-14 13:54:01 +08:00
rustdeskandClaude Opus 5.5 4c29c23bd3 test: remove tests that do not pin user-visible behavior
Apply the AGENTS.md test rules to the existing Rust and Flutter suites.
A test is kept when it is the only coverage of user- or API-visible
behavior, the regression test of a reported or fixed bug, a security
boundary (one test per rejection class), or a check against an external
reference. Tests that assert private state or the order of internal
steps, pin review-round corner cases, or repeat what a kept test already
covers are removed.

Rust: 679 -> 298 tests. Dart: 118 -> 45 tests.

Test-only helpers left without callers go with them
(applyLastOnlineStateForTesting, terminalKeyboardRowWidth,
debugBufferedInputCount, tokio's test-util dev feature), and cfg gates
and visibility that existed only for the deleted tests are narrowed.
Runtime behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R
2026-10-14 13:54:01 +08:00
Leo Camus 76ab280ae9 fix(terminal): trim the replay buffer when output has no newline (#16545)
* fix(terminal): trim the replay buffer when output has no newline

OutputBuffer::append returned early when the last line was incomplete
and the new chunk had no newline, so the trim loop never ran. A program
that writes without newlines (a minified file through cat, a \r
progress bar) grew the replay buffer past MAX_OUTPUT_BUFFER_SIZE
without bound, one 4 KiB PTY read at a time, until a newline arrived.

Signed-off-by: leoca <leo.camus23@gmail.com>

* fix(terminal): keep recent output when trimming a line without newline

Trimming popped the whole incomplete line once it passed
MAX_OUTPUT_BUFFER_SIZE, so the reconnect replay could come back empty.
Newline-free output now goes into a new chunk once the incomplete one
holds DEFAULT_RECONNECT_BUFFER_BYTES, and trimming drops the oldest
chunks while the newest bytes stay. The test checks get_recent.

Signed-off-by: leoca <leo.camus23@gmail.com>

* fix(terminal): trim an oversized incomplete line to its newest half

Splitting the incomplete line into chunks made get_recent return whole
chunks only, so a reconnect could replay as little as the newest read.
Keep one line and, when it alone is over MAX_OUTPUT_BUFFER_SIZE, drop
its oldest bytes down to half the cap. The copy then happens once per
half buffer instead of on every append, and the replay keeps the
newest DEFAULT_RECONNECT_BUFFER_BYTES.

Signed-off-by: leoca <leo.camus23@gmail.com>

---------

Signed-off-by: leoca <leo.camus23@gmail.com>
2026-10-14 12:35:53 +08:00
HeZheng 29a160fc7e fix(mobile): prevent late mouse-down after floating button release (#16541)
Signed-off-by: zhenghe <zhenghe2009@gmail.com>
2026-10-14 11:26:13 +08:00
Mariano Abad 49a9574f97 fix(drm): pace the producer to the rate the encoder takes, with the panel refresh as floor (#16423)
* fix(drm): pace the producer to the rate the encoder takes, with the panel refresh as floor

The root producer slept a fixed 33 ms after every grab, so every DRM stream topped
out at 30 fps whatever the client asked (rustdesk#16175). Measured on a
1920x1080@60 i915 box with the client at Custom 60 and QoS settled at 60: 30
grabs, 30 converted, 29.8 sent. A shorter fixed interval reaches the rate but
oversamples: at 8 ms the same box grabbed and converted 117/s for 60 sent.

The consumer now tells the producer the rate it encodes at, in the ack byte it
already sends per converted frame: 0x01 as before, or 0x80|fps. Every producer
counts ack bytes and never read their value, so an older service sees exactly
the same credit and keeps its 33 ms; the JSON handshake and the ack count and
timing do not change. The rate is the encoder's spf (what frame() is called
with), lowered to what the encoder achieves when it is busy longer than spf per
frame: the time from one frame() call to the next, less what the call spent
waiting for a frame, so a late producer cannot talk its own demand down.

The producer replaces the post-work sleep with a deadline: the encoder's period
above 30 fps, the old 33 ms from 30 down to 15, and half the encoder's period
below 15, never faster than the panel (the exact CRTC refresh from libdrmtap
0.5.9's drmtap_crtc_refresh, re-read once a second; with an older library the
whole-hertz refresh the enumeration already read, so no extra connector probe).
The worker still wakes at least every 33 ms, because each tick also reads the
cursor and a slow frame rate must not slow the cursor down. Without a demand (an
older consumer, or before the first frame() call) the old 33 ms sleep stays.
MAX_STALLED and the WouldBlock retry keep their 5 s budget, pinned by a const
assertion.

Tests: the ack byte round trip and that it is one byte on the wire, the drain
(seeded byte streams: every byte one credit, the newest demand wins), the pacing
interval table (panel floor, below 30 and below 15, a 23.976 Hz panel), the panel
period rounded up, the deadline and its overrun, the wake that keeps the cursor
at 33 ms, the demand from spf and from a busy encoder, the busy time without the
wait (pure and through frame()), the demand log once per connection, the
whole-hertz fallback, and the reader's refresh answer. Each rule has a mutation
that turns its test red.

* ci: run the drm pacing tests in the drm step

drm_pacing_tests (src/ipc/drm.rs) and crtc_refresh_tests (libs/scrap
drm_reader.rs) match none of the existing filters.

* test(drm): keep three tests, on outputs and not on internal state

Per AGENTS.md (Tests), keep one to three tests, on outputs and not on internal
state: the demand ack as it goes on the wire, the producer interval for a
demand and a panel, and the exact refresh the reader returns.

* test(drm): the ack test goes through the producer drain

Per review: send_frame_ack(60) read back through drain_frame_acks gives one
credit and a demand of 60, so dropping the demand read in the producer turns
the test red. The interval test also pins drm_panel_period(148352, 2475) to
16_683_294 ns.

* fix(drm): time the encoder only after a frame() call that returned a frame

Since #16450 frame() drops a repeat as WouldBlock. The time after such a
call is video_service sleeping out its spf, and after a timeout it is
about zero once the wait is subtracted. Neither follows an encode, so
neither is a sample of the encoder pace now. Uses returned_at from #16450;
no new state.

* fix(drm): ask for the old 33 ms tick while the screen is still

Since #16450 a still screen is grabbed at the demand and dropped in
frame(), twice as often as master at 60. After 100 ms without a new
frame the demand is capped at 30, the old producer tick, and the next
new frame lifts it. 100 ms is three of those ticks, so a producer paced
at 30 in motion never reads as still. Uses returned_at; no new state.

* test(drm): a dropped repeat or timeout is no encoder time; a still screen asks for the old tick

Per review: the demand frame() reports stays with a 50 ms encoder through
a still screen of repeats and timeouts, and a still screen asks for 30
after STILL_AFTER until a new frame brings back 60.

* fix(drm): start the encoder average over when the rate goes up

Per review: the samples hold the sleep video_service paces itself with, so
after a rate increase (custom fps 1 -> 60, or QoS recovering) an average
taken at the old rate kept the demand low and the producer slow. A
shorter spf than the last now starts the average over and drops the
sample that spans the change. The demand is computed in one method that
takes its time as input, the still cap included.

* test(drm): drop the two pacing tests of the last round

Per review: they read shared.demand_fps and slept for real. The three
tests stay.

* build: pin libdrmtap 0.5.12 (rustdesk-org/libdrmtap 5936a7a88)

The panel floor of the pacing reads drmtap_crtc_refresh(), added in
libdrmtap 0.5.9. The mirror carries v0.5.12 since 2026-10-13, so the pin
moves there.

The rest of the range from 0.5.8 changes no struct in drmtap.h: 0.5.10
reads its environment through secure_getenv and loads no GL in a setuid,
setgid or file-capability process, which rustdesk does not run; 0.5.11
makes drmtap_deswizzle, also the CPU fallback of a tiled grab, refuse a
destination stride narrower than its rows instead of writing past it,
and exports drmtap_frame_owns_data, unused here; 0.5.12 changes only
docs and the version.
2026-10-14 10:44:43 +08:00
0740063863 Keyboard shortcuts (#14933)
* fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848)

* feat(shortcuts): user-configurable keyboard shortcuts for session actions

  Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
  parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
  session actions. Bindings are stored in LocalConfig under
  `keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
  `pass_through` flags so flipping the master switch off is a hard stop.

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

  Design principles applied during review:

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

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

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

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

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

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

  Out-of-scope but included:
  - AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
    Web target's hand-written TS client, since both are load-bearing for
    any new FFI work.
  - remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
    monitors" / "Monitor #N"), unrelated to shortcuts but kept here.

* langs

* fix(keyboard): shortcuts, harden config and callback lifecycle

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

* feat(keyboard): shortcuts, color of "Reset to defaults"

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

* feat(keyboard): shortcuts, release keys before shortcut callback

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

* feat(keyboard): shortcuts, debug web

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

* fix(keyboard): iPad, icon

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

* 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

* 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

* 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

* 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

* 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

* 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

* fix(shortcuts): preserve input state and refresh action availability

* fix(keyboard): shortcuts, keep a fired key owned across session close

Close tab is itself a shortcut, so the session that consumed the key press
is gone before the key is released. Both matchers dropped the key with the
session: the Flutter dispatcher removed its own entries in clear(), and the
Rust set was keyed by session and cleared in session_close. The release,
and the repeats while the key stayed held, then reached the next session's
remote as ordinary input.

Ownership of a fired key is physical, so it is now one set shared by every
session on each side, and closing a session no longer touches it: clear()
only drops the pending action and modifier replay, and the Rust session
state only tracks released modifiers. A key whose release is missed heals
itself on its next press. Entering view-only with the Flutter input source
still clears the Rust set, since that source stops routing key events to it.

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

* fix(keyboard): shortcuts, drop the Toggle Input Source action

The action switches the key capture backend between key down and key up.
Fired-key ownership lives in whichever matcher saw the press, the Flutter
dispatcher or the Rust set, and there is no handoff between them: after the
switch the repeats and the release of the held key reach the other backend,
which forwards them to the remote and, while the chord is held, matches the
action again and toggles the source back. Remove the shortcut instead of
building a handoff for one action. The toolbar radio menu is unchanged.

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

* perf(keyboard): shortcuts, parse the config once per stored value

The Flutter matcher read the local config three times and parsed its JSON
three times on every key press in legacy mode, on Web and in view-only.
Keep one parsed snapshot keyed by the raw stored string, so a key press
costs one config read and no parsing while the config is unchanged. The
existing readers are thin views of the same snapshot.

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

* fix(keyboard): shortcuts, run focus-changing actions on key release

Close tab and tab switching move keyboard focus to another session before
the key that fired them is released. On Linux the Flutter input source
routes map-mode sessions through the Rust matcher and legacy-mode sessions
through the Dart matcher, and neither knows the other's fired keys, so
after such an action the repeats and the release of the held key reached
the next session's remote, and the key stayed recorded as fired in the
matcher that saw the press.

Routing Linux legacy mode through the Rust FFI path instead is not a drop-in
change: the raw key handler builds its event with no unicode, so Rust's
legacy mode would fall back to US key names and lose the layout-aware
characters the Dart path sends today, and it would take modifiers from the
X11 key state instead of Flutter's.

Run these three actions when the fired key is released. The matcher that
consumed the press then also sees the repeats and the release, the remote
modifiers are still released on the press, and a session that closes drops
the action it owed. Both matchers keep the same list of actions.

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

* test(keyboard): shortcuts, pin the key-up action list on both sides

kShortcutActionsRunOnKeyUp and runs_on_release are two hand-kept lists of
the actions that run on key release. Add a shared fixture and check each
side against it, the way the key vocabulary and the default bindings are
already checked, so adding a focus-changing action on one side breaks a
test until the other side follows. The Dart test also checks that every
listed id is a configurable action.

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

* fix: prevent lost input after remote session shortcuts

Avoid synthetic Alt presses that activate the Windows system menu
and swallow the next character.

Trigger restart, switch-sides, and chat actions on key release so
focus changes cannot leave subsequent shortcut presses ignored.

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

* fix(mobile): correct Chat keyboard and input helper behavior

- Keep soft keyboard and helper hidden when Chat opens via shortcut
- Restore soft input on touch and remote focus after Chat closes
- Handle modifier releases while Chat owns focus
- Clear hidden virtual modifiers while preserving held keys and Pin behavior

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

* fix(shortcuts): prevent recording non-simultaneous key combinations

Build new captures from currently held keys instead of reusing released
letters. Preserve the last valid chord after release so it can be saved.

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

* fix(shortcuts): isolate window actions and prevent duplicate Alt presses

- Dispatch shortcut actions only to the originating session window.
- Clear local modifier state on focus loss without resending modifiers
  already released to Windows peers, preventing lost following input.
- Strengthen the regression assertion against extra Alt keydown events

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

* refact(shortcuts): empty default shortcuts for Cmd+Option+Shift+Del on macOS and iOS

Because Del may be ambiguous for new macOS users.
Cmd+Option+Shift+Backspace is "Empty Trash without confirmation" shortcut on macOS.

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

* fix: use session peer platform for Ctrl+Alt+Del events

Fix Android-to-Linux Ctrl+Alt+Del delivery by using the session's peer
platform instead of the desktop global session, which defaults to Windows

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

* fix: hide relative mouse mode shortcuts on Wayland

Separate relative mouse mode availability from screenshot support.
Filter unsupported actions and defaults while preserving saved bindings

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

* fix: align shortcut availability with platform support

- Hide unsupported mobile and web shortcut actions
- Enable mobile scaling and Android voice call shortcuts
- Use openMonitorInTheSameTab() for display switching
- Preserve cursor position when switching displays on desktop and web

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

* fix: prevent held-key shortcut recording bypass

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

* fix: preserve keyboard input after shortcut dialogs

Run native clipboard shortcuts on key release and wait for remote modifier releases before dispatching.

Release remote modifiers when mobile local UI takes focus, and document the tested iPad Bluetooth Mute shortcut limitation.

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

* fix: preserve remote key state during local shortcuts

Keep unrelated held keys pressed for session actions that retain input. Preserve key release when focus or input handling changes, and avoid synthetic Alt taps during shortcut dispatch.

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

* fix: suspend saved relative mouse shortcuts on Wayland

Filter the native active binding cache without changing stored configuration, so the binding becomes available again under X11.

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

* fix: exclude unsupported Wayland shortcuts from Flutter matching

Keep saved bindings intact while filtering the active config used by the Flutter matcher. Cover stored bindings and enable/pass-through flags in the existing shortcut tests.

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

* fix: retain forwarded key ownership across shortcut repeats

Ignore repeat presses for keys already forwarded to the peer, and release ordinary held keys before Show My Cursor enables view-only. Cover both paths with converter-level regressions.

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

* fix: release Flutter-held keys before view-only shortcuts

Track ordinary keys forwarded by the Dart matcher paths and await their releases before View Only or Show My Cursor disables input. Keep Mute key ownership and existing empty-cleanup timing unchanged.

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

* fix: filter active Flutter shortcuts by platform capabilities

Reuse the Settings action filter for saved bindings while preserving storage, default-seeding behavior and configurable Apple Ctrl+Alt+Del bindings.

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

* fix: filter native shortcut caches for mobile controllers

Suspend saved desktop-only actions on Android and iOS and hide unavailable iOS actions without changing persisted bindings. Keep native cache filtering aligned with Flutter platform capabilities.

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

* fix: share filtered shortcut bindings with the Web matcher

Pass the active platform config to JavaScript at startup and after settings changes, preserving unsupported saved bindings in local storage.

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

* fix: release Flutter-held keys before focus-changing shortcuts

Drain ordinary keys owned by the original session before run-on-key-up
actions and native clipboard callbacks. Preserve Mute holds, pending-action
cancellation and Web clipboard key-down activation.

Document the required Web matcher counterpart in the bridge:
rustdesk/rustdesk-web-js@94abe431d6

Validation: 61 native Flutter tests, 32 Chrome/HTML tests, targeted analysis
and Windows debug build.

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

* refact: remove the useless comments

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

* fix: prevent Windows menu activation from Alt-only shortcuts

Neutralize the forwarded Alt with a balanced temporary Ctrl press when a consumed local shortcut releases its only modifier in Windows Map mode. Keep physical modifier tracking and normal Alt pass-through unchanged.

Add a regression covering both Alt keys, trigger repeats and the eventual physical release. Verified with 36 native shortcut tests and real Windows menu/input checks.

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

* fix: prevent Alt menu activation in all keyboard modes

Extend Windows Alt-only shortcut neutralization to Translate and Legacy. Keep the temporary Ctrl packets in Map mode so Legacy modifier synchronization cannot release Alt before Ctrl.

Extend the existing regression across keyboard modes and decode scan, virtual and control-key events. Verify 36 native tests and 34 Windows menu replay cases.

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

* fix: initialize outgoing chat before shortcut dispatch

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

* fix: guard Windows Alt release in Flutter legacy shortcuts

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

* fix: coordinate Web shortcut dispatch and Alt modifier cleanup

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

* fix: distinguish AltGraph from ordinary Alt shortcuts

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

* fix: dispatch macOS Web shortcuts without waiting for keyup

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

* fix: support legacy peers in privacy mode shortcut

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

* fix: exclude AltGraph from Flutter shortcut matching

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

* fix: exclude GTK AltGr input from raw shortcut matching

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

* test: trim redundant keyboard shortcut coverage

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

* fix(keyboard): prevent Cmd shortcuts from opening remote Start

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

* fix(shortcuts): guard Windows menu keys after Ctrl/Cmd swapping

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

* fix(shortcuts): align recording and display routing

Reject non-Apple AltGraph captures to match runtime filtering. Honor individual-display windows for next, previous, and all-display shortcuts.

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

* fix(shortcuts): reject GTK AltGr chords in recorder

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

* fix(android): restore keyboard focus for modifier-first input

Focus the existing FlutterView when a modifier is pressed without native view focus. This restores shortcut-first input after returning to RustDesk while preserving focused inputs and normal key dispatch.

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

* fix(shortcuts): release held keys before keyboard mode changes

Drain Flutter-forwarded keys before keyboard-mode shortcut actions so releases use the original encoding. This prevents held keys from remaining down after a Web Input source 2 mode change.

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

* fix(i18n): use sentence case for shortcut labels

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

* fix(mobile): preserve touch modifiers in the input helper

Keep touch-selected modifiers visible after hiding the keyboard. Clear physical modifier releases locally in view-only mode so they do not reopen the helper.

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

* fix(web): preserve modifiers across Legacy-to-Map shortcuts

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

* fix(shortcuts): check support before registering all displays view

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:31:44 +08:00
RustDeskandClaude Opus 5.5 13e1278b4c fix: auto-login types the OS password only at the logon or lock screen (#16520)
* fix: auto-login types the OS password only at the logon or lock screen

A remote-control login on Windows and macOS now reports is_logon_screen
in platform_additions, and the client skips auto-login when the peer
reports false. Peers that don't report it (older ones, Linux) keep
today's behaviour, so this narrows the leak into the focused window
without closing it: the value is a snapshot taken at login.

Windows 7 and Server 2008 R2 report the WTS lock flag reversed, so
there only LogonUI.exe tells the lock screen apart.

Refs #16510, #15261

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

* windows: drop the LogonUI.exe check from is_logon_screen

LogonUI.exe can outlive the unlock and is matched by name only, so its
presence reported a lock screen on an unlocked desktop and let the
password through. Only the WTS lock flag decides now; on Windows 7 and
Server 2008 R2, which report it reversed, the result is inverted.

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

* windows: count only a confirmed logon or lock screen for auto-login

is_prelogin() reads a failed WTS user-name query as no user signed in,
and on Windows 7 the inverted is_locked() read a failed lock-flag query
as locked, so either failure let the password through. is_logon_screen
now uses its own queries that keep a failed read apart, and reads LOCK
and UNLOCK the other way round on Windows 7 and Server 2008 R2. The
existing functions stay as they are for their callers.

If the check task itself fails, the server reports false instead of
leaving the key out, which old clients' peers would mean.

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

* auto-login: ask the peer with a protobuf field instead of a JSON key

The client sets the new LoginRequest.auto_login only when Auto Login
would type the OS password, and the server runs the logon-screen check
only then, so nothing new runs for any other login. The answer is the
new PeerInfo.skip_auto_login: its default, false, is what an old or
Linux server means, so those peers keep typing as before, and neither
field is encoded unless set. The is_logon_screen key in
platform_additions is gone.

Windows is_locked() now reads the lock flag through
is_session_locked_flag(), which handles the reversed LOCK/UNLOCK of
Windows 7 and Server 2008 R2. Its only caller is the
allow-logon-screen-password check, which on those systems accepted the
permanent password on an unlocked desktop. Windows 8 and later read the
same as before. The C is_session_locked() is removed and its
WTS_CURRENT_SESSION guard moves into get_session_flags().

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

* auto-login: let the peer type the password, after checking its screen

The login-time check left the whole time until the password arrived
open: the screen could be unlocked during the click, the 1.2 s wait and
two network trips, a second client's password landed on the desktop the
first one had just unlocked, and turning Auto Login on mid-login skipped
the check.

Now the client still clicks and waits, then sends the password in a new
Message.os_login (reusing OSLogin) to a Windows or macOS peer from 1.5.1.
The peer checks for its logon or lock screen right then and types the
password and Return only if it finds one; otherwise it drops them. Other
peers get the keys as before, and the OS Password menu item is
unchanged. The password goes out only after login, never in the login
request. LoginRequest.auto_login and PeerInfo.skip_auto_login are
removed, so nothing new runs unless os_login arrives.

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

* server: name os_login in scope-violation logs

A rejected os_login was logged as message.other.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:06:15 +08:00
rustdesk 0b5a3d74de 1.5.1 2026-10-12 17:08:40 +08:00
Mariano Abad 109c39bccb fix(drm): drop a frame equal to the previous one, as the other capturers do (#16450)
* fix(drm): drop a frame equal to the previous one, as the other capturers do

The producer grabs on a timer, so a still screen reached the encoder as the
same frame every tick and the client decoded about 30 frames per second of
nothing new (rustdesk#16449). The x11, pipewire and quartz capturers drop a
repeat with would_block_if_equal; the drm capturer now does the same.

A regression test checks that a repeat is not encoded again and that the next
new frame is. The frame helper of the tests now differs per call, and the 180
test forgets the frame its last case repeats on purpose.

* fix(drm): a frame whose encode failed is not dropped as a repeat

The capturer marked a frame as seen when it returned it, so when its encode
failed and the screen then stayed still, every later grab compared equal to
it and it was never sent: the client kept the previous image, and a hardware
encoder never reached its failure count to switch away.

handle_one_frame now also says whether the frame was encoded, and when it was
not the loop calls TraitCapturer::forget_last_frame(), a new method whose
default does nothing. The DRM capturer then returns the next repeat instead of
dropping it.

The DRM capturer also stops keeping a copy of the last frame: it compares the
new buffer with the one it returned last before replacing it, and a turned
frame is turned into a spare buffer first.

* fix(linux): x11 and pipewire do not drop a failed frame as a repeat either

Both capturers drop a frame equal to the one they returned last, so they had
the same hole as the drm one: a frame whose encode failed was not sent again
while the screen stayed still. They now forget it when the loop says so.

* fix(linux): every video run starts by forgetting the last frame

A pipewire recorder outlives a restarted run while another display keeps the
session open, and so did the frame it returned last. A run restarted by an
encoder switch or a new subscriber then dropped the first repeat and sent
nothing until the screen changed. The capturers of the other paths are built
fresh for each run, so for them the call does nothing.

* fix(drm): keep the repeat drop in the drm capturer, with one repeat a second

The shared part of the three commits before is reverted: TraitCapturer,
the video_service loop, x11 and pipewire are as on master again. So is the
restructure of frame() from the second commit: the drop is again the one
of the first commit, would_block_if_equal on the frame returned last, as
the other capturers do.

A repeat is dropped until a second has passed since frame() last returned
a frame, so a frame whose encode failed reaches the encoder again a second
later even if the screen stays still. The tests check the drop, the repeat
a second later, and that the second starts over after it.

* fix(drm): a frame is a repeat only when its format matches too

The same bytes as BGRA and as RGBA are two pictures when red and blue
differ, and the convert path can return either, so a change of format
clears the saved frame and the new one goes out. The 180 test no longer
checks a turned repeat: nothing reported asked for it.

* fix(drm): compare a frame with cur before replacing it, without a copy

saved_raw_data copied every changed frame (8 MB at 1080p). The previous
frame is still in cur when a new one arrives, so the bytes and the
format are compared with it before the swap; a rotated output turns the
frame into a spare buffer first. The 180 test checks the turned repeat
again, since that path compares on its own now.
2026-10-12 11:29:57 +08:00
Henrik Fransson ccfa714d2e Fix Swedish translation typo (#16529)
Signed-off-by: Henrik Fransson <hmf.hmf.hmf@gmail.com>
2026-10-12 10:57:11 +08:00
fufesou 0921a7432f feat(update): verify signed update metadata (#15914)
* feat(update): verify signed update metadata

* fix(update): harden metadata publishing and app names

- share strict app-name validation across Windows and macOS
- preserve published sidecars until replacements are ready
- select macOS test features by target architecture
- update the signing dependency and URL validation coverage

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

* fix(update): prevent replacing published release assets

- block reruns after signed update metadata is published
- clarify the security rationale for app-name validation

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

* fix(update): cache TLS responses and avoid double translations

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

* refact: reduce the diffs

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

* refact(update): remove obsolete update paths

- remove unused FFI update branches
- delete legacy Windows and macOS update helpers
- simplify macOS daemon updates and redundant gating

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

* feat(update): secure same-identity macOS offline updates

- verify DMGs using signed metadata, size, and SHA-256
- enforce package and bundle identity plus macOS signature checks
- support custom release policies and propagate manual update failures

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

* fix(update): verify Windows installers with Authenticode

Reject unsigned or untrusted EXE/MSI updates
before elevation, clean up failed verified copies,
and fix the macOS manual updater logging import.

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

* fix(update): clear stale URL on response body errors

Keep body-read failures in the common cleanup path and document the
cache-only Authenticode verification policy.

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

* refactor(update): remove OS signature sanity checks

Remove Windows Authenticode and macOS codesign/Gatekeeper verification.
Keep metadata, artifact integrity, bundle identity, and transaction checks.

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

* refact(update): move update scripts into res/update

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

* fix(update): secure Windows MSI handoff against junction races

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

* fix(update): secure Windows EXE handoff against junction races

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

* fix(update): preserve legacy custom client app names

- allow names starting with digits or underscores
- preserve leading hyphens on Windows for compatibility
- reject leading hyphens on macOS to avoid pgrep option parsing

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

* refact(update): remove some simple wrappers

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

* refact(update): simple refactor

* refact(update): comments on update.scpt

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

* refact(update): comments

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

* fix(update): create download destinations exclusively

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

* fix(macos): roll back updates when final readiness checks fail

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

* fix(msi): propagate MSI installation failures

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

* chore

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

* fix(macos): reject stale IPC sockets during updates

Require open IPC sockets in addition to launchd running state before committing the privileged update transaction. Inspect sockets with lsof to avoid triggering IPC authentication.

Add native socket regression coverage to macOS CI. Remove the unused Windows helper, combine offline metadata exports, and restore unrelated formatting changed by the PR.

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

* fix(macos): allow offline updates without an installed bundle

Validate app-only updates against the running client's bundle identifier and check the destination identity when it exists. Replace source-text assertions with behavioral identity tests and run the macOS update tests in CI.

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

* ci, gate test with PUBLISH_UPDATE_METADATA == 'true'

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-10-12 10:56:50 +08:00
fufesou c8532719c0 Fix/macos mouse release (#16516)
* fix(macos): preserve mouse-up through cursor protection

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

* fix(macos): clear tracked presses released in relative mode

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

* fix(input): preserve desktop mouse releases through cursor protection

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

* fix(input): preserve button state when mouse-up is rejected

Clear tracked buttons only after coordinate validation succeeds.

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-10-10 19:05:45 +08:00
RustDeskandClaude Opus 5.5 4411d10f45 fix(linux): report the process user as the active user under Flatpak (#16519)
File transfer to a Flatpak peer always failed with "No active console
user logged on", because PeerInfo.username came from loginctl, which the
sandbox cannot reach: Flathub builds lack the org.freedesktop.Flatpak
talk-name, so `flatpak-spawn --host` fails. A Flatpak instance only runs
inside the logged-in user's own session, so that user is the active one.

Fixes #16506


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:44:10 +08:00
67090d3aa1 fix: a refused privacy-mode request no longer turns off another connection's (#16507)
* fix: a refused privacy-mode request no longer turns off another connection's

`Connection::turn_on_privacy`'s error arm tore down whatever privacy mode was
active, whoever owned it. With connection A in privacy mode, connection B's
request is correctly refused with OCCUPIED, and the error arm then called
`turn_off_privacy_to_msg(INVALID_PRIVACY_MODE_CONN_ID, ..)`. That id bypasses
the ownership check in `check_off_conn_id`, so A's privacy mode went away: the
screen was un-blanked and local input un-hooked. The call passes `state: None`,
so no `set_privacy_mode_state` goes out either and A was never told, leaving its
client showing privacy mode as on. `check_privacy_mode_changed` doesn't cover
it, as it only sends PrvOnByOther while a privacy mode is still active.

Pass `self.inner.id`, as the sibling error path above already does.
`check_off_conn_id` then cleans up after this connection when it owns privacy
mode, and refuses when another connection does.

The bypass itself stays: `ipc::Data::Close`, `reset_all` and `TurnOnGuard` need
an unconditional teardown. Its contract is now noted on `check_off_conn_id`,
since that is what the buggy call site got wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Steven Storie <sstangle73@gmail.com>

* fix: disconnect other remote sessions when privacy mode starts

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

* fix: exclude privacy disconnect hook from iOS builds

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

* fix: validate privacy mode before disconnecting other sessions

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

* chore

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

---------

Signed-off-by: Steven Storie <sstangle73@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-10-10 16:02:29 +08:00
fufesou 001b0e9617 Fix/preserve deleted printer (#16500)
* fix(windows): preserve manually deleted printers

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

* fix(windows): distinguish printer detection failures

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

* fix(windows): skip printer detection when disabled

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

* fix(flutter): load printer status asynchronously

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

* refactor(flutter): remove printer status mounted checks

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

* fix(msi): preserve deleted printers in direct upgrades

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

* fix(windows): preserve deleted printers in silent EXE installs

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

* fix(windows): skip printer installation when detection fails

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

* fix(installer): default printer off when detection fails

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

* fix(printer): limit checked enumeration to presence queries

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

* fix(installer): allow cancellation during printer detection

Inline MSI update command construction without changing its arguments.

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

* fix(flutter): time out installer printer detection

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

* chore

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

* test(windows): cover multilingual install app names

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-10-10 00:21:17 +08:00
Hintay 0bc2473ffe fix(drm): size a plane-rotated scanout as the mode transposed (#16444) 2026-10-09 14:11:42 +08:00
fufesou 3f4bfe535e Fix/wayland input keyboard layout (#16462)
* fix(linux): respect Wayland keyboard layouts for uinput

Resolve printable keys from GNOME input sources or the Wayland keymap and KDE active layout group. Refresh layout metadata on input at most once per second, and preserve the keycode and owned modifiers until release.

Preserve the original Legacy character on client key release and handle Caps Lock shortcuts without introducing an unwanted Shift modifier.

Validated with GNOME/KDE full-service input and disconnect checks, plus Windows/Linux native keyboard capture and release checks.

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

* fix(linux): harden Wayland keyboard layout detection

Keep the last usable map on transient discovery failures and preserve the
legacy character path when no map is available, with explicit warnings.
Retain each injected key representation through release and handle IBus
default layout and variant metadata without compiling a layout named default.

Use native GNOME and Plasma sources, including KWin sessions without the
optional layout service. Read the Xwayland keymap and effective group from
the same keyboard on other desktops. Load XCB/XKB libraries dynamically and
accept the unix/:N local display spelling.

Validation: 24/24 temporary Linux production-module and IPC checks passed,
including an actual read-only Xwayland query. The two latest routing/display
regressions failed before their fixes. Rustfmt and git diff --check passed.
No Cargo/Flutter commands or full remote application tests were rerun.

Xwayland state freshness and synchronous desktop I/O remain known limits.

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

* fix(linux): bound input waits for layout refresh

Run throttled Wayland layout discovery in a single background worker and give the triggering input a 25 ms wait budget. Later input uses the existing map without waiting or queuing more queries. Preserve source error handling and skip known Legacy clipboard-only text.

Document Xwayland freshness and per-character modifier ownership limits. Desktop calls remain uncancelled; the budget bounds input waiting, subject to OS scheduling.

Validation: Linux module compilation with cached dependencies, 23 existing temporary module/IPC checks, and stalled-XCB timing probes passed. Rechecked cache/error/release cases after the final lock-scope change. No permanent regression tests or Cargo/Flutter commands.
Signed-off-by: fufesou <linlong1266@gmail.com>

* docs(linux): record layout query latency measurements

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

* fix(linux): reply to failed uinput key state queries

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

* fix(linux): prefer non-keypad character mappings

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

* fix(linux): preserve text after unmappable uinput characters

Continue processing a text sequence when one character cannot be resolved
in the host layout. Return the first mapping error after the supported
characters are injected, so the existing throttled log still reports failure.

Keep modifier-query, injection and release errors fail-fast. This fixes
German a^bc losing bc without adding clipboard or fixed-US fallback.
The unsupported caret remains an explicit mapping error.

Validation: three temporary Linux checks (nine inputs) passed using the
production resolver and sequence methods, real framed IPC and native XKB
decoding. The suffix check failed before the fix. Formatting and diff checks
passed; no permanent tests, Cargo/Flutter commands or full app build added.

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

* fix(linux): preserve Latin shortcuts on non-Latin layouts

Detect Ctrl/Alt/Meta independently of CapsLock. If the active XKB map lacks
an ASCII shortcut letter, reuse the existing physical letter mapping while
preserving explicit Shift and the press-time key for release. Existing
layout mappings and unsupported ordinary text retain their behavior.

Honor disable-uinput-layout-fallback and log this compatibility path with
the existing throttle.

Validation: 12 temporary Linux production-module/IPC checks passed,
including 43 non-Latin shortcut/layout/lock/Shift cases. The new checks
failed before the fix. Formatting and git diff --check passed. No real
mobile/compositor session or Cargo/Flutter build was run.

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

* fix(linux): preserve missing punctuation shortcuts

Allow missing ASCII graphic shortcuts to reuse the existing physical key
table when Ctrl, Alt or Meta is active. This restores Ctrl+[ and Ctrl+]
with a valid Russian layout instead of discarding their character events.

Retain the table's Shift requirement for punctuation and use existing
modifier ownership and press-time release tracking. Uppercase letters do
not synthesize Shift. Active-layout mappings, ordinary text errors and
disable-uinput-layout-fallback retain their behavior.

Validation: 14 temporary Linux production-module/IPC checks passed after
recompiling the final source with rustc and cached dependencies. Includes
19 punctuation/lock/Shift combinations and 43 existing Latin-shortcut
cases. The two new checks failed before the fix. Formatting and
git diff --check passed. No Cargo/Flutter build or real mobile/native
Wayland session was run.

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

* fix(linux): respect held Shift when resolving shortcuts

Derive Shift-held candidates from the same XKB keymap and query the
caller's Shift state for character shortcuts. On US layouts, Ctrl+Shift+<
now uses the comma key instead of the extra ISO key that produces >.

Keep caller-held Shift out of synthesized modifier ownership. Preserve
ordinary input and letter-shortcut identity, and do not replace an
ordinary shortcut with a keypad alias to satisfy the held-Shift lookup.

Validation: 16 temporary Linux production-module/IPC checks passed with
native XKB event replay and cached dependencies via rustc. Includes
30 US text/shortcut cases and 40 UK/DE/FR character/lock cases. The new
Ctrl+Shift+< check failed before the fix. Changed sections pass formatting
and git diff --check. No Cargo/Flutter build or real mobile/native Wayland
session was run; device-state and packet-modifier handling use fixtures.

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

* docs(android): document paired-punctuation input limitation

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

* docs(linux): explain Xwayland layout synchronization limits

Document the focus-scoped modifier updates observed on GNOME and KWin. Core and physical X11 keyboards can retain an old layout group while native Wayland windows have focus; reconnecting or waiting cannot request the missing update.

Validation: 16 existing module/IPC probes and the live GNOME mapping probe passed. Live KDE queries reproduced native French versus stale Xwayland US. Formatting and diff checks passed. This is a comment-only change; strict Xwayland-only behavior is preserved.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): query native Sway and Hyprland keyboard layouts

Read the uinput keyboard's active layout through compositor IPC and
validate its group against the native Wayland keymap. Match the IPC
server to the Wayland session, and accept numbered Hyprland device
names while rejecting ambiguous keyboard selections.

Validation: 21 temporary production-module and framed-IPC checks passed.
Live Sway US/FR group and a/q mapping checks passed with Xwayland disabled;
the GNOME French layout probe also passed. Formatting checks passed.
A live Hyprland session and a full application build were not tested.

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

* fix(linux): defer unverified native compositor layout queries

Remove the Sway/Hyprland adapter that paired a uinput device's layout
group with a seat keymap based only on matching layout names. Different
device options can make a synthesized AltGr key toggle CapsLock instead.

Restore the existing best-effort Xwayland source for other desktops and
document the device-keymap information missing from the examined native
queries. GNOME/KDE discovery and injection/cache behavior are unchanged.

Validation: 16 temporary production-module/IPC checks passed after the
removal, plus read-only GNOME French and Xwayland mapping checks. Rust
formatting and diff checks passed. No full application build or mobile
end-to-end test was run for this follow-up.

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

* fix(linux): reject unreliable GNOME per-window layout sources

GNOME restores per-window layouts without persisting MRU. Reject MRU
selection when per-window input sources are enabled with multiple sources,
and invalidate the cached map and source identity for this typed error.
Transient discovery failures still retain the last valid mapping.

Reuse the existing logged/disableable compatibility policy. This contains
stale-map acceptance; it does not add effective per-window layout discovery.
Single-source and global-source GNOME configurations retain their path.

Validation: the old code failed the temporary stale-cache regression.
All 3 focused production-module checks pass after the fix, covering cache
invalidation/recovery, compatibility modes, single/default source mapping,
UK punctuation, and last-good retention after an IBus connection failure.
Compiled with rustc and cached Linux dependencies; formatting/diff checks
passed. No permanent tests, Cargo/Flutter build or mobile session added.

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

* fix(linux): reject inactive IBus layout sources

Read the current GlobalEngine description instead of looking up the
persisted MRU engine by name. GNOME can suppress IBus for password entry
without updating MRU, leaving the old engine's layout valid but inactive.

Report an engine-ID mismatch as UnreliableSource so the existing cache
invalidation path drops that mapping. Keep transient query-error handling
and layout/variant normalization. Do not infer a US compositor layout from
GNOME's xkb:us::eng echo engine.

Validation: the old code failed the inactive-engine cache and default-layout
checks. All 3 temporary production-module/D-Bus checks pass with this fix,
covering invalidation/recovery, compatibility policies, metadata sentinels,
query failures and ordinary French XKB selection. Compiled with rustc and
cached Linux dependencies; formatting/diff checks pass. No permanent tests,
Cargo/Flutter build or live password/mobile session added.

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

* fix(linux): prewarm uinput layout discovery at startup

Start the existing layout worker when the uinput keyboard client connects,
before the server accepts remote input. Reuse its throttle and single-worker
logic; keep the existing 25 ms input wait and background completion policy.
Prewarming reduces cold-start fallback without introducing input queues or
promising readiness while a desktop query remains outstanding.

Verified with three temporary Linux production-module checks using cached
dependencies: a 125 ms query resolves UK @ before first input, a 200 ms
outstanding query stays nonblocking and publishes its map, and a failed
startup query retries successfully. All three fail their startup assertions
before the change. No permanent tests or Cargo/Flutter builds were added.

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

* fix(linux): preserve first input wait during layout prewarming

Keep the startup query's completion receiver until the first relevant input
takes it. That input can wait for the already-running query within the
existing 25 ms deadline, without holding the receiver handoff lock. Later
input remains nonblocking and the existing single-worker guard is unchanged.
Prefer a due refresh to an old completed startup notification.

Verified with four temporary Linux production-module checks compiled with
rustc -C opt-level=3 and cached dependencies: joining a pending UK query,
one bounded wait followed by nonblocking input, due refresh after prewarm,
and recovery after query failure. Both lost-wait checks fail before the fix.
Formatting and git diff --check pass. No permanent tests or full build added.

An unoptimized delayed-query probe still exceeded 25 ms and used fallback;
this restores the wait opportunity, not an initial-readiness guarantee.

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

* fix(uinput): remove unnecessary change

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

* refactor(linux): simplify uinput character releases

Release the mapping stored for the same character. Linux character input already pairs down/up events, so opposite-case matching is unnecessary.

Validation: git diff --check passed. Static comparisons confirmed that Map-mode dispatch, raw-key injection and keycode offsets match the PR base. No Cargo/Flutter commands or live-session tests were run.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): normalize Unicode CapsLock shortcuts

Normalize single-scalar Unicode lowercase for CapsLock shortcuts without adding Shift. For lowercase expansions, resolve the original symbol with the actual CapsLock state instead of truncating it. Document the existing first-seat discovery limitation.

Validation: two temporary Rust context checks against lookup fixtures failed before the fix and passed afterward. No Cargo/Flutter build or end-to-end input test was run.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): derive CapsLock shortcut keys from XKB

Keep the actual CapsLock state when resolving shortcut characters. Use the existing Caps-off and Caps+Shift mappings to omit generated Shift only when XKB confirms the same physical key and remaining modifiers, preserving the Turkish I/ı and İ/i key identities.

Validation: three temporary production-module checks passed on 192.168.5.32 with native libxkbcommon; two failed before this fix. Covered Turkish, German, Cyrillic and ASCII shortcuts, explicit Shift, ordinary text and UK punctuation. No full application build, IPC or end-to-end input test was run.
Signed-off-by: fufesou <linlong1266@gmail.com>

* refactor(linux): remove CapsLock shortcut special cases

Use the existing lock-state synchronization and ordinary XKB mappings for mobile soft-keyboard input. Remove CapsLock-specific shortcut remapping and compatibility-path lowercasing while retaining held-Shift punctuation handling.

Validation: rebuilt the Linux Rust library and reused the unchanged Flutter UI. Actual Android Gboard input matched UK punctuation on GNOME and KDE; French/German switching on KDE passed after the refresh interval. The user confirmed real iPad input on both hosts, including smart quotes. Formatting and whitespace checks passed. The optional Ctrl/Shift toolbar automation was inconclusive and is not counted as passing coverage.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): press Level3 before Shift for uinput characters

Preserve mapping candidate preference while storing generated modifiers in Level3-before-Shift order. This avoids activating Compose with lv3:ralt_switch_multikey; releases retain their existing reverse order.

Verified on Linux with temporary production-mapper/libxkbcommon tests: Lithuanian ! fails before this change and passes afterward; all 24 UK punctuation/lock-state cases pass with released modifiers and unchanged lock state. Formatting and diff checks pass. No full application build or mobile end-to-end test was run for this change.

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

* fix(linux): preserve legacy uinput IPC without a keymap

Send sequences and character clicks through their original IPC messages when the layout cache is unavailable, avoiding unnecessary synchronous key-state queries. Keep throttled fallback warnings and leave layout discovery, retry policy, and key-down/up pairing unchanged.

Verified with three temporary production-method checks on Linux using a recording transport and injected query errors. The unavailable-map routing check fails before the fix and passes afterward; raw-click routing and available-map query-error handling remain unchanged. Changed-code formatting and diff checks pass. No full application build or end-to-end input test was run.

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

* fix(linux): skip layout queries for legacy uinput fallback

When no keymap is available, single-character input must use the legacy
KeyDown/KeyUp path without querying lock or modifier state first. Retain
the legacy key in the existing press record so discovery completing
before a repeat or release cannot change the selected representation.

Validated with four temporary production-method checks on Linux using a
controlled cache/resolver and recording transport: missing-map fallback
with query failures, press/release across cache transitions, unchanged
raw-key dispatch, and Ctrl+C without extra Shift or queries. The initial
fallback check failed before this change. Valid-map errors still surface.

Changed-code formatting and git diff --check pass. No full Cargo/Flutter
build or live service/mobile test was run for this change.

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-10-09 11:17:04 +08:00
Mariano Abad 9b9e5f113a fix(wayland): place a portal stream at the only output of its size (#16451)
* fix(wayland): place a portal stream at the only output of its size

xdg-desktop-portal-hyprland reports every stream at (0, 0). With no output at
the origin, sort_streams dropped the stream; with an output of another size
there, the stream took its origin and skipped try_fix_logical_size, so the
client sent physical pixels from the wrong origin and the pointer landed on
another output.

With more than one output, a stream whose position is not the origin of an
output of its size (a rotated output counts in either orientation) now takes
the origin of the only output of that size; with several outputs of that size,
or none, the position is kept. A position found by try_fill_positions names an
output of its size, or no output has its size, so later calls keep it while the
outputs and the measured size do not change.

Refs #15731

* fix(wayland): reconcile portal positions with measured sizes only

The first commit corrected any position that named no output of the stream
size, trusting the size. That size is the portal size when get_res() fails,
and it can be the mode of another output, so a correct portal position could
move onto the wrong output. A stream now records whether its position came from
the portal and whether its resolution was measured, and a portal position is
overridden only by a measured size.

Streams without a portal position go through try_fill_positions as before; one
it leaves unresolved, such as a rotated output whose transposed size no mode
matches, now takes the origin of the only output of its size.

Refs #15731

* fix(wayland): only a measured size moves a stream position

The reconciliation after try_fill_positions let a position through to
corrected_origin unless the portal had sent it. A position that
try_fill_positions restored from its cache is not from the portal, so when
get_res() failed and the size was the portal size, a cached (1920, 0) with a
1440x900 portal size moved to the 1440x900 output at (0, 0), and with both
outputs shared sort_streams dropped one of the streams. Only a measured size
moves a position now, whatever its source, so position_from_portal goes away.
2026-10-09 11:15:16 +08:00
21pages 1d4abd0258 fix: resolve HTTP API 407 errors through HTTPS proxies (#16496)
Upgrade reqwest to 0.12.28 to include the fix for missing
Proxy-Authorization headers when forwarding HTTP requests
through HTTPS proxies.

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-10-08 17:55:59 +08:00
NetViperandRustDesk 6da7977a94 fix(video): defer refresh teardown until hw encoder warms up (#16459)
* fix(video): defer refresh teardown until hw encoder warms up

On macOS, hevc_videotoolbox (the only encoder there reporting
!latency_free()) can return no packet for its first encode call. The
OPTION_REFRESH check in run() tore the encoder down with
bail!("SWITCH") inside that window, so each new encoder was destroyed
before producing a frame and rebuilt about once a second. With H265
selected on macOS 27 / Apple silicon, the picture froze or the session
dropped every 1-3 minutes.

On macOS, once the encoder has been fed a frame, defer the refresh
until it emits its first packet. Before that there is no warm-up to
lose, so a refresh can still restart a stalled capturer. Other
platforms and every latency-free encoder behave as before. An encoder
that never emits is still switched away from by the unchanged
max_fail_times path. The only new state is one flag.

Verified on macOS 27.0 / 27.0.1, M5 Pro, 2560x1440 H265. Soaked for two
weeks on a 1.4.9-based build carrying this deferral:
- before: ~40 "no valid frame" and ~27 refresh teardowns per 60s
- after:  37 sessions, every "no valid frame" at times:1, so
          max_fail_times was never approached; H265 never lost

Fixes #16457

Signed-off-by: David Riding <48262524+dmriding@users.noreply.github.com>

* Update video_service.rs

---------

Signed-off-by: David Riding <48262524+dmriding@users.noreply.github.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-10-08 11:14:56 +08:00
fufesou 25d2c6db55 log key-up and text injection failures (#16455)
* fix(macos): log key-up and text injection failures

Report failed key releases through the rdev input path and
missing sources, mappings, or events in the Enigo keyboard path.
Throttle repeated failures without logging text contents or changing input behavior.

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

* fix(macos): refine keyboard failure diagnostics

Remove key identities from virtual-input and Enigo failure logs while preserving operation and failure details. Also report key-down event creation failures in key_click with the existing throttle, without changing event posting or error propagation.

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

* refact(logs): update rdev, add diagnostic logs

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-10-08 00:23:14 +08:00
Stephan Paternotte f0bd880fe7 Update nl.rs (#16485)
lang: Dutch language file updated
- various consistency fixes
- ... -> …
- empty values translated
2026-10-07 21:33:18 +08:00
Ricardo Simões a916af8f00 Translate voice call audio capture prompt to pt-PT (#16482) 2026-10-07 18:56:05 +08:00
rustdeskandClaude Fable 5.1 9f9585ce15 Remove ConfigUpdate handling
Clients no longer apply a rendezvous-server list pushed by the
rendezvous server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkpVy2ufxgEjKd3SjXPnJ8
2026-10-06 17:17:06 +08:00
Andrzej Rudnik e8a865924b Updated Polish translation (#16456) 2026-10-06 10:58:02 +08:00
memory_clearandfufesou c9c0b5d0ef Fix translation for voice call audio capture prompt (#16433)
* Fix translation for voice call audio capture prompt

* fix: translation, audio capture prompt, wrong "Share screen"

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-10-05 20:36:22 +08:00
5406950b02 fix(macos): open Screen Recording settings from Configure (#16452)
CGRequestScreenCaptureAccess shows its prompt only once per app. After
the user denies it, or after an update changes the code signature,
pressing Configure on the Screen Recording banner did nothing. Open the
Screen & System Audio Recording pane as well, so the button always
leads somewhere the user can grant access.

Co-authored-by: Eric Mason <17150+ericmason@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:15:32 +08:00
Mariano Abad 685fa2e4a1 fix(drm): the startup cache warm does not wake sleeping displays (#16437)
* fix(drm): the startup cache warm does not wake sleeping displays

Every `_drm` connection ran the display wake before the service answered with
its list, so the cache warm that each --server start runs (the service restarts
the --server every hour) woke displays that had idled off, with no client
connected (rustdesk#16356).

The --server now opens the handshake with DrmHello{wake}, and the service wakes
and settles only when asked. The warm asks without a wake; the availability
probes, the login refresh and the capture stream keep it, since a client is
waiting for them. Without a wake the service still clears the wake latch of a
connector it sees lit, as the warm did.

A warm that finds nothing lit leaves the cache without a verdict, where its own
wake used to make it Available. So a login that the DRM path can serve now
probes on a missing verdict before anything reads the cache (the greeter check,
the PipeWire prompt, the display list). That probe wakes the displays as the
warm did, but only when a client logs in.

* fix(drm): throttle the log of a peer that does not open with DrmHello

AGENTS.md: a log call that can fire at a rate a peer controls must not use
debug or higher unthrottled. Same 5 s interval as the rejected-IPC logs in
ipc/auth.rs.

* fix(drm): the startup warm keeps a list a login published while it queried

Per review: the warm samples the state generation before its query and
publishes only if no other probe published since, the check the two
background refreshes and the login refresh already use.

* fix(drm): log a failed login settlement task instead of dropping its error

Per review: the JoinError of the blocking task was discarded with `let _ =`.
2026-10-05 19:59:08 +08:00
Zixun LI e5bc204fe4 fix(wayland): avoid clock waits in PipeWire capture (#16430) 2026-10-03 06:51:33 +08:00
Cheolhee LeeandClaude Opus 5.5 fada664df7 fix(clipboard): unix, keep Windows directories searchable (#16406)
A directory pasted from Windows with FILE_ATTRIBUTE_READONLY or
FILE_ATTRIBUTE_HIDDEN (Git for Windows hides `.git`) was given mode 0444 or
0400 in the FUSE list, because the read-only and hidden branches came before
the directory branch. Without `x` the directory cannot be entered once a
mode-preserving copy lands it on disk. Windows does not restrict access to a
directory for either attribute, so give every directory 0755, as a plain one
already gets.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1.5.0
2026-09-30 23:50:12 +08:00
21pages 9d3ae7acd2 fix(windows): restore Flutter view visibility after FancyZones placement (#16397)
* fix(windows): restore Flutter view visibility after FancyZones placement

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

* fix(windows): avoid stale resize targets during Flutter refresh

* fix(windows): update multi-window dependency for FancyZones

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-30 23:30:33 +08:00
Cheolhee Lee a7f2260203 fix(clipboard): unix, don't wrap FILETIMEs past 2554 (#16385) 2026-09-29 10:18:58 +08:00
berce 40a4aa34c8 Don't retry the _pa ipc at a Linux login screen (#16390)
At a greeter the cm is not started, so the `_pa` socket never exists and
the audio service fails to connect and logs an error once per second for
as long as a client is connected. Wait in `pa_impl::run` while
`is_prelogin()` is true; audio starts once a user logs in.

Fixes #16012
2026-09-29 10:17:06 +08:00
fufesou 0c18a8cbc9 fix(flutter): restore multi-window rendering after reconnect (#16383)
Use the window's current display when lastUserDisplay is unset so
additional monitor windows restore their capture subscriptions.

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-29 10:16:55 +08:00
rustdeskandClaude Opus 5.5 4812a9815b AGENTS.md: limit corner-case growth and test bloat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:34 +08:00
b6b11fd9b1 fix(drm): apply the uinput range on one thread instead of holding a lock across the await (#16362)
* drm: apply the uinput range on one thread instead of holding a lock across the await

#16122 serialized the three paths that apply a uinput range in a DRM
session (the layout poll, `update_uinput_resolution` and `check_init`)
with a tokio Mutex held over the awaited `update_mouse_resolution`,
which AGENTS.md rules out ("Do not hold locks across `.await`"; greptile
on #16122). The serialization itself has to stay: the uinput service
keeps the range of whichever request reached it last and each apply
sends it over its own connection, so two overlapping applies can leave
the device on one range while the other one is recorded.

The check, the apply and what it records now run as one job on a
dedicated thread (`run_uinput_apply`), one job at a time in the order
asked, with the runtime of that thread for the IPC and its 3 s timeout.
The async paths await the answer and the display service loop blocks on
it; nothing holds a lock while it waits. The poll publishes its drift
flag inside its job, as it did under the lock. A thread that failed to
start or died is started again for a later job, so one failure does
not stop every later apply (the poll used to build a runtime per attempt
and retry). The drm-off build keeps the code it had.

Tests (in `input_map_tests`, which CI runs):
uinput_applies_run_one_at_a_time_in_the_order_asked,
a_uinput_apply_job_can_bound_its_ipc_with_a_timeout,
a_uinput_apply_thread_that_died_is_started_again. Mutations: every job
on its own thread, the apply runtime without timers, the answer never
sent, a dead thread not started again; each turns a test red. The call sites run IPC and are
covered by reading.

* drm: log a layout poll whose uinput apply job got no answer

The layout poll dropped the result of its job with let _ =, so a poll
whose job was dropped (the apply thread failed to start or went away)
left no trace, while update_uinput_resolution and check_init log it
(greptile and CodeRabbit on #16362). It logs now. The drift flag keeps
what the last poll that ran published; a later poll starts the thread
again and publishes it.

* drm: throttle the uinput apply thread failure logs

The layout poll hands the apply thread a job every 1.5s, so a thread that
cannot start logged two errors per poll for as long as a session lasted.
Keep one line per site every 10 minutes, with the count of the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:27:08 +08:00
Cipher Professor d1722c5d6d fix(linux): use BUS_VIRTUAL for the virtual mouse device (#16382)
The uinput virtual mouse RustDesk creates on Linux reported bustype
BUS_USB. libinput's evdev_tag_external_mouse() tags any BUS_USB or
BUS_BLUETOOTH pointer device as an external mouse, so desktop
environments' "disable touchpad when an external mouse is present"
setting suspends the user's real touchpad whenever RustDesk creates
this device, even though nothing is physically plugged in.

Changed the constant to BUS_VIRTUAL (0x06, verified against the
current upstream kernel's include/uapi/linux/input.h), which
correctly identifies this as the software-only device it is.

Fixes #16318.

Signed-off-by: Mohsin Manzoor Bhat <mohsinmanzoor1913@gmail.com>
2026-09-28 17:03:42 +08:00
Mariano Abad 0d49ead0c3 fix(drm): turn a captured frame only when the plane did not rotate it (#16345)
* fix(drm): turn a captured frame only when the plane did not rotate it

A compositor rotates an output either in hardware, setting the primary
plane's `rotation` property, or in software, drawing the scanout already
turned. `wl_output` cannot tell the two apart, and `frame_transform`
guessed: 90/270 turned, 180 left alone, which is right for i915 + mutter
(rotate-180 in hardware, scanout upright) and wrong wherever the
compositor drew the scanout turned, where 180 comes out upside down.

Measured before changing it. virtio-gpu (no rotation property, mutter 46):
the scanout dump at 180 is the desktop upside down, at 90 and 270 the
logical desktop turned inside the native mode framebuffer. i915 + mutter
(GNOME 50) at 180: the plane reports rotate-180 and the dump is upright,
identical to the unrotated one. amdgpu + KWin 6 at 180: the plane stays at
rotate-0 and the dump is upside down.

The producer reads `drmtap_plane_rotation()` (libdrmtap 0.5.8, optional
symbol) right after each grab and stamps it on the frame, in the dma-buf
descriptor and in the CPU frame header, both `serde(default)` so an older
producer still parses. A plane that rotated scans out the logical desktop
whatever the output transform says: the compositor programs it from the
CRTC transform, which also folds in the connector's panel orientation that
`wl_output` never carries, so the consumer leaves such a frame alone. A
plane at rotate-0 scanned out what the compositor drew, and the frame is
turned by the whole output transform. `None` (a library before 0.5.8, or
nothing it could read) keeps the previous rule, so nothing changes until
the library says otherwise. The session is still sized from the output
transform: a plane that rotated 90 in hardware hands over the portrait
scanout those dims already name.

* build: pin libdrmtap 0.5.8 (rustdesk-org/libdrmtap 95d4d7454)

The rotation fix reads drmtap_plane_rotation(), added in libdrmtap 0.5.8. The mirror carries that commit since 2026-09-25, so the pin moves there.

* drm: pin the decoding of a frame from a producer older than the plane rotation

The root service and the --server are upgraded separately, so a frame
header or a dma-buf descriptor without `plane_rotation` must still
decode, as None, which keeps the previous rule. The tests built both
messages with the field set to None; this one decodes payloads that omit
it, and one that carries it (greptile on #16345). Serde already reads a
missing Option as None, so what the test pins is the wire name and the
None on absence, for both messages.

Mutation: renaming the field on the wire, in either message, turns it red.

* drm: take the plane rotation right after the grab, not after the copies

`drmtap_plane_rotation()` reads the rotation property of the plane the
last grab came from at the moment it is called. The cpu path asked only
after `DrmReader::grab()` had copied the frame into its buffer and the
producer had copied it again into the message, so on a large frame the
compositor had time to change the plane and the frame went out with the
rotation of the next one; `frame_transform` turns that frame by it with
nothing to absorb the skew (zhou, review of 26-sep).

`DrmReader` now reads the rotation right after a successful
`drmtap_grab_mapped` or `drmtap_grab_desc`, before any copy or release,
and `plane_rotation()` returns that snapshot, so both paths stamp a frame
with the rotation its own grab saw.

Test: a_frame_keeps_the_rotation_its_plane_had_when_it_was_grabbed, in
`plane_rotation_tests` (which CI already runs), drives the reader through
a fake library whose frame release turns the plane: the rotation read at
the grab survives on the cpu grab and on the dma-buf export. Mutations:
no snapshot on the cpu grab, none on the export, and the cpu snapshot
taken after the release, each turn it red.
2026-09-28 15:54:55 +08:00
flusheDData be9b96cf65 Translate voice call instruction to Spanish (#16358) 2026-09-28 13:53:54 +08:00
Cheolhee LeeandClaude Opus 5.5 89fcf56c4e fix(clipboard): unix, don't let the next file's preload fail a request (#16364)
After serving a request for file N, `serve_file_contents` preloads the
next non-directory file with `load_handle()?`. When that file can no
longer be opened (deleted or renamed after the copy, or unreadable), its
error replaced N's reply, so every size and range request for N failed
although N itself is intact.

Keep the preload best-effort. Its failure is logged at trace, because it
can fire on every request for N, and N+1's own request still reports the
error.

Signed-off-by: chlee <sourcehatchery@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:46:34 +08:00
Cheolhee LeeandClaude Opus 5.5 8a76628007 fix(clipboard): unix, use the real Windows FILETIME epoch offset (#16366)
`LDAP_EPOCH_DELTA`, the offset from the FILETIME epoch (1601-01-01) to
the Unix epoch in 100 ns units, was 116444772610000000. The real offset
is 116444736000000000 (11644473600 s), so the old value is 3661 s
(1h01m01s) too large.

Unix peers encode and decode with the same constant, so the error cancels
between them, but Windows sends and reads real FILETIMEs: files copied
from Windows got a modified time 1h01m01s too early on Linux and macOS,
and files copied from Linux or macOS were sent one 1h01m01s too late.

Signed-off-by: chlee <sourcehatchery@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:37:03 +08:00
Winston b5d8b979d8 i18n(tw): use the Screen Share page label in the voice call hint (#16355)
The hint referenced the "Screen share" page as 「螢幕分享」, but the
actual Traditional Chinese label of that page is 「僅分享螢幕畫面」
(the value of the "Screen Share" key). Match it, as zh-CN does with
"仅共享屏幕".

Signed-off-by: Mosquito1123 <zhangwentong1123@icloud.com>
2026-09-26 15:08:51 +08:00
fufesou 44683d108b fix(macos): clear Finder progress after clipboard transfers (#16346)
* fix(macos): clear Finder progress after clipboard transfers

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

* test(macos): cover single-response clipboard completion

Verify progress completion, handle cleanup, and finalized file contents.
Move function-only Finder imports into progress helpers.

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

* fix(macos): unpublish clipboard progress before renaming

Complete and unpublish NSProgress before the final file rename to avoid
stale Finder and Dock progress indicators.

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

* chore: add ref

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-26 15:03:55 +08:00
Mariano Abad 50258e21e9 fix(drm): measure the cursor hotspot on drivers without HOTSPOT_X/Y, confirm before publishing (#16122)
* drm: carry the cursor plane position on the frame header

CursorSnapshot gains the plane position x/y. It rides DmabufDesc.cursor_pos
and Data::DrmFrame.cursor_pos as a serde-default Option, so a producer that
predates the field reads as None on the consumer, and a consumer that predates
it ignores the key. The producer reads the cursor once per delivered frame,
after the grab, so a WouldBlock retry does no cursor work; the position is a
few ms newer than the frame, which the consumer tolerates because it only
counts consecutive identical positions. The consumer destructures the field
and does not use it yet.

* input: remember the last absolute peer move and forget it on any other move

The DRM cursor calibration subtracts a cursor plane origin from the point this
process injected, so the point has to be the post-remap uinput one and nothing
else: LATEST_PEER_INPUT_CURSOR stores an X-server coordinate on the relative
arm. The new sample carries an Instant, a sequence number (two measurements
against one sample are not independent) and the layout generation it was
mapped against. A relative delta and a move this process makes itself forget
it. display_service exposes the existing drift flag to readers.

* drm: resolve the cursor calibration context once, with the capturer

CalContext { rect, physical_size, built_gen } is computed in
IpcDrmCapturer::new from the same wayland snapshot the transform comes from,
and only when the snapshot is complete, the output is an identity match, the
unfolded transform is 0 (180 included) and the geometry is valid. It is
stored in Shared before the transform's Release store, so a receive thread
that has seen the transform sees the context. Nothing reads it yet.

* drm: measure the cursor hotspot and confirm it before publishing

On a driver without HOTSPOT_X/Y the wire carries infer_hotspot's guess, which
the bitmap cannot get right for a shape whose click point the theme put where
the alpha does not mark it. This process injects the tip and the plane origin
rides every frame: once both are still, tip - origin is the hotspot.

Only a guessed shape is measured. The first measurement is a candidate and
changes nothing visible. A second measurement confirms it only from another
stable plane position and another peer sample, and then the retained
candidate is the one value that reaches the cache, the served hotspot, the id
remix and the delivery. Near what is served, nothing changes, even when the
value is also near the wire, so a correction a few px from the wire does not
flap; near the wire otherwise, the wire is served and the cached correction is
dropped. A stable position is measured at most once, a layout generation
change stops measurement once the display service's poll has seen it, and the
cache is read only behind the context gate, so a correction measured upright
never reaches a rotated output.

Each of these gates has a test that goes red with the check removed or
altered (file restored byte for byte afterwards):
  publish the first measurement          -> retained candidate, same sample, still plane, local nudge
  same sample confirms                   -> same sample never confirms, local nudge
  confirm with the new value             -> retained candidate, near wire, in band
  drop the near-wire arm                 -> near wire serves the wire
  drop the in-band arm                   -> in band changes nothing visible
  drop the re-store after clear-on-cap   -> in band stays cached
  measure a kernel-measured hotspot      -> never calibrated at the origin
  read the cache without a context       -> not served without a context
  drop the once-per-position flag        -> still plane measured once
  drop the transform gate                -> context only for an upright output
  swap phys and extent                   -> the arithmetic, the scaled context
  drop the clear-on-cap                  -> cache bounded
  drop the partial-snapshot gate         -> context only for an identity match
  drop the generation check              -> closed intervals
  compare the wire before the served     -> in band with both keeps the served correction

* drm: a cached hotspot correction is served only under the generation it was confirmed in

The calibration cache mapped a wire cursor id to its confirmed hotspot
and nothing else. A pair of stops taken inside the layout poll window,
after a monitor moved and before the poll saw it, could confirm a value
measured against the old layout; the generation gate then stopped that
stream, but the capturer rebuilt after the promotion seeded its served
hotspot from the cache and replayed the value under the new layout, until
another pair of its own replaced it (zhou, review of 25-sep).

Each entry now carries the layout generation it was confirmed under, and
a stream reads the cache under its own `built_gen` only: a value from
another generation is not served, the wire hotspot is, until a pair on
this generation confirms one. Checked at the read rather than by clearing
on promotion, and an entry is replaced or dropped only by its own
generation or a newer one, the bound included (at the cap the cache
clears what the writer's generation or an older one confirmed, and
caches nothing if only newer entries are left), so a receive thread
still running on the old layout can neither serve its value to the
rebuilt stream nor undo the value that stream confirmed.

Tests: a_correction_confirmed_under_an_older_generation_is_not_served (two
stops under one generation, the stream rebuilt under the next, its own
pair, then the old stream writing late),
the_bound_never_drops_an_entry_from_a_newer_generation. Mutations:
reading without the generation filter, replacing or dropping a newer
entry from an older generation, and at the cap either clearing
everything or caching when only newer entries are left, each turn one
of them red.

* drm: calibrate only against samples injected under the layout's adopted uinput range

`usable_sample` took the remap flag being clear as "the input mapping is
ready". It is not: the display service bumps the layout generation and
promotes the baseline before the uinput range update completes, and a
failed or pending update leaves the device mapping absolute coordinates
with the range of the previous layout. Every sample then carries the new
generation while its point lands scaled by the old range; two stops share
that error and agreement confirms it (zhou, review of 25-sep: a desktop
narrowed from 4000 to 3800 px puts a hotx of 4 at 54, inside the bitmap).

The display service now keeps the layout generation whose uinput range
the device runs, and a count, raised when an apply starts and again when
the device acknowledges it. Both move when `update_mouse_resolution`
returns Ok, on the DRM session-init path (also run by the hotplug
worker) and in the layout poll; the session-init path labels the range
with the generation read before it computed the rect, and the raw DRM
union it falls back to when the compositor cannot be asked with no
generation, so no stream measures on it until the poll applies or finds
a compositor range. A range that already fits the layout moves the
generation only, so the samples injected under it stay valid. From the
moment an apply starts, on any path, no generation is ready, the count
is raised and the recorded rect is forgotten, so an apply that fails or
times out (the device may still take the range late) leaves nothing
ready, and only an acknowledged apply records a generation again. The
poll and `update_uinput_resolution` run on different threads; they
check, apply and record under one lock. `check_init`, which a display
falling back to PipeWire runs inside a DRM session, records its apply
the same way (cfg(feature = "drm") lines only; the drm-off build is
unchanged). It has no fits check and applies on every run, as before, so
each run moves the count. A path that read its generation before the
poll bumped it records under the older one; no stream measures then
until a later layout check records the current one. A sample is stamped
under the ENIGO guard of its move, which an adoption also needs for its
refresh, so a move stamped with the count of an adoption was injected
after that adoption's refresh. The count is raised before the generation
is published and read after it. A stream measures only while that
generation is its own; each peer sample carries the count at injection
and is a reference only under the current one; a candidate measured
under one count starts over instead of confirming under the next. The
remap flag is untouched.

Tests: a_stale_input_mapping_publishes_nothing_until_fresh_samples_follow_the_adoption
(generations equal, no drift, the device on the previous range: two
stops publish and cache nothing; after the adoption a sample from before
it does not count, and fresh ones confirm),
a_sample_is_a_reference_only_under_the_adopted_input_mapping,
a_candidate_does_not_confirm_across_an_input_mapping_change,
an_acknowledged_range_is_an_adoption_and_a_fitting_one_is_not,
the_raw_drm_union_labels_a_range_with_no_generation,
an_apply_leaves_no_range_ready_until_it_is_acknowledged; the drm CI step runs
input_map_tests. Mutations: dropping the adopted-generation gate,
dropping the count gate, confirming across counts, an adoption that does
not count, a fitting range that counts, the union labelled with the
generation, and an apply that keeps the recorded rect or leaves a
generation ready, each turn one of them red. The call sites (the lock,
the calls before and after each apply on the three paths, the range-fits
branches) run IPC and are covered by reading.

* drm: forget the peer sample under the mouse lock on a move of this process

The temporary-move thread (the display probe `fill_displays` runs in the
PipeWire init, `check_init`) forgot the last absolute peer sample before
it took ENIGO. A peer move holding ENIGO at that moment moved the pointer
and recorded its sample after the forget, and the temporary move then
landed on top: the pointer somewhere else, the sample still the peer
point, with the current generation, count and a fresh sequence, so two
such overlaps could confirm a wrong hotspot (zhou, review of e6103daea).

The forget now runs under the lock the peer move records its sample
under, right before the move (`forget_sample_and_move`), so a peer move
lands entirely before the forget or entirely after the move. The drm-off
build keeps the line it had. The relative path of the peer already
forgets under the ENIGO guard of its move.

Test: a_move_of_this_process_forgets_the_sample_of_a_peer_move_it_waited_for
(the peer holds a stand-in lock when the move asks for it; nothing is
forgotten before the move has the lock; the peer records its sample and
releases it, and after the move the sample is gone). Mutations: the
forget taken before the lock (red in five runs of five) and no forget at
all, each turn it red. The call site in the thread, which passes ENIGO,
is covered by reading.
2026-09-26 14:59:19 +08:00
Winston 47a2a3c83d i18n(tw): fill missing Traditional Chinese translations (#16353)
Fill the three empty entries in src/lang/tw.rs, mirroring the existing
zh-CN (cn.rs) translations and reusing the terminology already used
elsewhere in the file:

- terminal-clipboard-write-tip
- Allow terminal apps to copy to clipboard
- To start a voice call, enable "Audio capture" on the "Screen share" page.

Signed-off-by: Mosquito1123 <zhangwentong1123@icloud.com>
2026-09-26 12:32:43 +08:00
Tayfur YıldızandTayfurYldz 17feabbcab fix(windows): allow existing session to foreground (#16349)
Signed-off-by: TayfurYldz <238304586+TayfurYldz@users.noreply.github.com>
Co-authored-by: TayfurYldz <238304586+TayfurYldz@users.noreply.github.com>
2026-09-26 11:46:58 +08:00
RustDeskandClaude Opus 5.5 08affc69c4 Fix clipboard range overflow (#16329)
* 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>
2026-09-26 11:25:12 +08:00
RustDeskandClaude Opus 5.5 e9ddbd8f46 cursor: bound the decoded cursor caches on the Flutter client (#16304)
* cursor: name a shape by what it looks like, not by its handle

A cursor id was the platform's handle for the shape: HCURSOR on
Windows, the XFixes serial on X11. Apps mint a new handle for a shape
they have shown before (Chrome, Electron, Qt custom cursors), and an
X11 animated cursor gets one per frame, so one arrow arrived under
thousands of ids over a session. The controlled side cached and sent
it again under each, and the controller decoded, rendered and kept it
again under each, and grew by gigabytes over weeks of connection.

The controlled side now names a shape by an xxh3 hash of its size,
hotspot and pixels, taken once when a handle is first seen, before
the pixels are compressed. The per-connection send already sends a
shape once and afterwards only its id, so a shape now crosses once
however many handles it has. The id is opaque in the protocol, so
controllers of any version accept it. The DRM backend already named
shapes by content with FNV-1a; it uses the same hash now.

A peer before 1.5.0 still names shapes by handle, and the fix has to
work against every deployed peer. For those peers the controller
hashes each CursorData's compressed colors with its geometry: one
peer compresses the same pixels to the same bytes, so a shape seen
before is recognised without being decompressed. The UI gets that
shape once, under one id, and every later handle for it is passed on
as a cursor_id of that id. The map from the peer's ids is kept for the
connection, unbounded, since the peer sends a shape once and may
select any id it named again; an entry is two integers. From 1.5.0
the controller passes the peer's ids through unchanged, on the old
path.

The web core in flutter/web does the same in its own tree.

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

* cursor: hold a content id to what the web client can read

The web client decodes a u64 into a JS number and throws on a value
past 2^53, which drops the whole message. A full 64-bit xxh3 id is
almost always past it, so a web client connected to a peer on this
build would never have been given a cursor shape. DRM's ids were
already full width, so web clients lost the cursor on DRM peers too;
the controlled side now renames those as well.

The id keeps the hash's low 53 bits. Two of ten thousand distinct
shapes then collide with a chance of about one in two hundred million.

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

* cursor: hand the UI a shape's pixels as they are, not as text

A shape's pixels went to the Flutter UI as a JSON array of integers
inside the cursor_data event: the core serialised a 1 MiB shape into
up to 4 MiB of text on the receive loop, and the UI parsed that into a
million-element list and copied it into bytes, on the UI thread. The
event, text and all, was then kept for the session so that a tab
moved to another window could replay it, and a reconnect, on which the
peer sends every shape again, appended another copy of each.

The core now sends a shape as an EventToUI::Cursor variant carrying
Vec<u8>, which flutter_rust_bridge hands to Dart as a Uint8List, on
the same per-session stream as the events so that it keeps its order
with the cursor_id events around it. The web core calls onCursorData
with the Uint8Array, as it calls onRgba for a frame. The id stays
text, since the peer's ids can exceed Dart's int.

The replay keeps each shape's pixels once, by id, and encodes them as
base64 only when a tab moves. A shape arriving is also recorded as the
last one selected, since the replay goes in first-seen order; before,
a tab moved right after a new shape came back on the previous one.

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

* cursor: let a controller ask the peer for a shape again

A peer sends each shape once per connection and afterwards only its
id, so a controller must keep every shape it was sent for as long as
it is connected. The controlled side now answers
Misc::request_cursor_data with the CursorData of that id, from the
shapes it has sent, and only to a connection the cursor service
would send it to. A controller from 1.5.0 on can then bound what it
keeps and ask again for a shape it let go. None asks yet: the peer has
to have the answer before a controller can rely on it, and peers are
updated last.

The shapes are kept by content id, and every handle for a shape
shares its one message, where each handle used to keep its own copy.
They are dropped with the service's own cache.

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

* cursor: keep a shape as its ui.Image and nothing else

Each shape the UI had been sent stayed in four copies for the session:
the pixels for a tab move, an image package copy to resize from (on a
macOS or Linux controller of a Windows or Linux peer, a second one
decoded back from a PNG), the bytes handed to the native cursor, and
the ui.Image painted when the peer moves the pointer.

A shape now keeps only its ui.Image. The resize source and the native
bytes exist while a native cursor is being registered and are dropped
once it is; a shape shown again at a raster it was registered at needs
neither. For a raster it lacks, the pixels are read back from the
ui.Image, which returns them exactly as they came. A tab move reads
every shape back the same way, and the moved window decodes them as
it would have on arrival.

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

* cursor: pin that a released shape keeps neither its pixels nor its bytes

The shape in use once registered, the shapes not in use, and the
pixels read back for a moved tab are all dropped; the tests now say so,
so that a later change keeping one of them fails.

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

* cursor: keep one native cursor per shape, and each tab its own

A shape got a native cursor for every raster it was shown at, and all
of them stayed until the session ended, so every zoom or DPR change
added a native cursor for each shape in use. A shape now keeps the one
at its current raster; the one it replaces is deleted the next time a
cursor already registered is built, by when its successor has been
activated. A raster returned to before that takes its cursor back
instead of registering another under the same name.

The native cursors of a window's engine are shared by its tabs, and
the predefined cursors had one name in all of them, so a tab closing
deleted the default and forbidden cursors the other tabs still showed.
Names are now scoped to the tab's cursor model.

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

* cursor: keep the shapes compressed in the core, and only the one in use in the UI

The UI kept every shape as a ui.Image, one raw copy each, for as long
as the session lasted, because the peer sends a shape once and a
shape might have to be drawn again. The core receives the shapes
compressed, at a few hundredths of that, and now keeps them so:
Session::cursor_shapes holds each shape that decoded, as the peer sent
it, under the id the UI knows it by. The colors are copied, since the
message's may be a slice of a larger received buffer.

The UI keeps a ui.Image for the shape in use alone. A shape shown
again at a raster its native cursor has needs no pixels at all; for a
raster it lacks, for painting it, or for the window a tab moved to,
the UI asks the core through session_get_cursor_shape, which
decompresses and checks the shape again with decode_cursor_data, the
same size and decompression limits as on arrival. A shape the core
cannot give is not asked for again until the peer sends it. A tab
move now carries only the id in use, and nothing is read back from
the GPU.

The web core keeps the shapes the same way and gives them back through
getCursorShape.

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

* cursor: a decode that finishes after the session clears keeps nothing

A shape asked of the core while a tab closed was decoded after the
cursor model had been cleared and stored in it, where nothing disposed
of its ui.Image again; a shape whose cache failed to build left its
ui.Image undisposed as well. A clear now starts a new generation, and a
fetch or decode from an earlier one disposes what it made. A shape the
core gave but that did not decode is not asked for again on every
frame it is painted.

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

* cursor: keep a native cursor for the shape in use alone

Every shape the peer showed kept a native cursor for the session, each
a raster the size of the shape as displayed. A shape switched away
from now gives its native cursor up the way a replaced raster does:
deleted the next time a cursor already registered is built, taken back
if the shape returns first. A shape shown again after that is rebuilt
from the compressed copy the core keeps. The predefined cursors are
not the peer's shapes and stay registered.

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

* cursor: keep the native cursors of the 16 shapes used last

Keeping a native cursor for the shape in use alone rebuilt one on
nearly every switch, since the pointer moves among a few shapes: the
arrow, text, hand, four resize arrows, wait. The native cursors of the
peer's shapes are now kept in order of use, 16 of them, and the one
used longest ago gives its cursor up the way a replaced raster does.

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

* cursor: forget the native cursors a session clear deletes

The clear deleted every registered native cursor but kept their names,
so a cursor model used again, as the mobile client's is, took each
name for registered and showed a cursor that no longer existed.

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

* cursor: hold a native cursor to 512 pixels a side

A native cursor could be registered up to 1024 pixels a side, four
MiB of pixels each, which with sixteen kept made 64 MiB at worst. The
largest cursor a system shows is 256 pixels (Windows' largest pointer
size), so a raster twice that is shown no larger: the cursor is drawn
smaller rather than rejected, and sixteen kept come to 16 MiB at most.

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

* cursor: a shape switched away from while it decodes is not marked lost

A shape asked of the core was taken for undecodable when it was no
longer kept once its decode finished, but a shape the peer switched
away from in the meantime is let go on purpose, and marking it made
every later request for it be skipped: its native cursor stayed
deferred and its painted cursor missing for the rest of the session.
The decode now reports whether it decoded, and only a failure marks
the shape.

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

* flutter: leave two untouched signatures as they were

dart format had rewrapped registerEventHandler and sessionSetCommon
next to the cursor additions.

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

* cursor: read a shape's pixels from where its view starts

The pixels used to be copied out of the JSON event into a fresh list,
so the list's buffer began at its first pixel. Now they come straight
from the core, native or web, as whatever Uint8List it hands over, and
img2's fromBytes takes the buffer, not the list, so a view into a
larger buffer would have been read from the buffer's start. Neither
core gives a view today; this stops depending on that.

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

* cursor: a DRM cache reset keeps the shape it was making room for

The shape being sent was filed in CURSOR_SHAPES before the DRM ceiling
cleared it with everything else, so a controller asking for the shape
on screen got nothing until the next new shape.

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

* cursor: delete a replaced native cursor at the next build that shows one

A replaced native cursor was deleted only by a build of a cursor
already registered, so a run of new shapes, each shown once, left
every replaced cursor registered: 16 on the books, all of them in the
system. Now every build that shows a cursor deletes the ones replaced
before it, right before it registers or takes one back; what replaced
them was activated in an earlier frame, so they are off screen. A
build that has to ask for pixels shows nothing new and deletes
nothing, so a raster returned to right after being replaced still
takes its cursor back; one returned to later registers again.

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

* cursor: a shape that does not decode leaves no handle and no copy

CursorDedupe named a handle as it hashed the shape, before the shape
was checked, so a peer sending shapes that fail the checks under new
handles grew the map with nothing the UI could ever select, and such
a shape took over the handle of one that had decoded. The handle is
now named once its shape decoded, or was shown before.

The flutter build also copied a shape's compressed colors out of the
message before the checks, so a message with a small size and huge
colors was copied whole before being rejected. The copy is made once
the shape decoded; until then the message's own bytes are held.

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

* cursor: remember the shape the core lacks only while it is in use

Every id the core could not give was kept in a set for the session,
so a peer sending cursor_id after cursor_id of shapes it never sent
grew it by a string each, at a few bytes of traffic per entry. The
mark exists so that painting does not ask for the shape in use on
every frame, and the shape in use is one, so one mark is kept: the
peer selecting another shape forgets it, and selecting a lost shape
again asks once more.

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

* cursor: forget the shape the core lacks once the peer selects another

The mark was replaced only by another shape the core lacked, so a
lost shape selected again after a known one was still not asked for.
Selecting any other shape now clears it.

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

* cursor: leave the DRM cursor's own id as it was

The wire id is cursor_content_id whatever the platform, set in
run_cursor over the shape's pixels, so the id the DRM reader derives
for itself only tells one hardware cursor from the next and keys the
service's cache. Which hash it uses does not reach a controller; the
change to xxh3 and the dependency it brought to scrap go.

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

* cursor: say the 1.5.0 gate means the release

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

* cursor: say why the peer answers request_cursor_data with what it holds

No controller sends it yet, and a review asked for it to go because a
cache reset on the peer could leave a request unanswered. A shape the
peer's cache dropped is rebuilt, and indexed again, the next time it
is shown, before any connection names it by id, so the shape a
controller has just been told to show is always there to give; that
is the contract a future bounded controller relies on.

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

* cursor: drop the tests nothing can fail

a_handle_is_named_once_its_shape_is_shown dates from when `name`
wrote the handle map. `name` is pure now, so its last assertion
cannot fail, and the others repeat two neighbours.
every_handle_the_peer_named_stays_for_the_connection fails only for
a bound added on purpose, which the struct's doc rules out. The Dart
arrival test is covered by the first test of its file.

The kept-shape test now asserts what `cursor_shape` is for, a copy
rather than a view of the received buffer, in place of the unknown
fields it drops. The content-id test moves out from between two
`use` lines.

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

* cursor: let the kept-shape tests run without the flutter feature

CI's cargo test builds the default features, and the two functions
under test are pure, so they compile for every test build and the
module loses its feature gate.

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

* cursor: keep native cursors for every frame of an animated cursor

An animated cursor is a shape per frame, 18 for the Windows busy cursor
and 23 for KDE's wait cursor. With 16 native cursors a cycle longer than
that missed on every frame: fetched from the core, decoded, registered
again and the replaced cursor deleted, about thirty times a second for
as long as the peer was busy. 64 holds two such animations and the
everyday set besides.

The tests settle in 10 ms instead of 100 ms, so the ones that fill the
limit stay quick.

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

* cursor: release only the shapes that can hold pixels

Selecting a cursor swept every shape of the session to release pixels,
though only two can hold any: the shape selected before, and a shape
that finished decoding after the peer moved on. Release those, so a
cursor_id costs the same however many shapes the peer has shown.

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

* cursor: show the native cursor limit holds two animations and the everyday set

64 is a working set, not the longest animation: KDE's wait and progress
cursors are 23 frames each, and a session that meets both plus a dozen
static shapes must keep them all, or every turn rebuilds.

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

* cursor: let the shape in use keep its pixels for a new raster

Dropping them once the native cursor was registered meant every scale
change, zooming with the cursor zoomed or crossing to a display of
another scale, showed the system arrow while the core gave the shape
back and it decoded again. The shape in use now keeps them and makes the
new raster at once; a shape switched away from still keeps none.

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

* cursor: never fall back to the default cursor on a switch

A switch to a shape without its image, or without a native cursor at
its raster, showed the default arrow until the core gave the shape back
and it decoded: the painted cursor on every switch back, the native one
after an eviction or while a new shape decoded.

- The shape shown before stays, image, hotspot and native cursor, until
  the one in use is ready.
- Where the cursor is painted, on mobile or with the remote cursor
  shown, the images of the 64 shapes used last are kept, like the native
  cursors, so an animation does not decode a frame per turn. Elsewhere
  only the shape in use keeps one.
- A shape that finishes decoding after the peer moved on is kept with
  them instead of dropped, so a fast animation fills them at all.

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

* cursor: name the dedupe's content-id set for what it holds

`shown` read as the shape on screen. It is the shapes decoded and given
to the UI, keyed by content id, so a shape arriving under a new handle
is looked up once instead of scanning the handle map: `decoded`,
`has_decoded`, and `record` for the call that fills both maps. The web
mirror is renamed the same way.

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

* cursor: keep native cursors for the session, as before

Evicting native cursors and deleting a shape's replaced raster made
Windows leak more than master. The engine's deleteCustomCursor/windows
frees the HCURSOR with DeleteObject, which does not take a cursor, so a
delete frees nothing and each cursor made again for a raster, or for a
shape shown once more, leaks another handle. Keeping every raster of
every shape until the session is cleared creates each one once, as
master does.

Fixing the engine instead is not cheap: x64 runs our fork on Flutter
3.24, where the fix is one more patch to rebuild and carry across every
upgrade; arm64 runs the stock engine of a newer Flutter with the same
code, where it means porting the fork and publishing an arm64 engine
too, or waiting for an upstream fix to reach stable. Content ids keep
the count to the shapes the peer really shows.

The painted images, the compressed shapes in the core, and a switch
showing the cursor shown before are unchanged.

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

* cursor: record the Windows measurement behind keeping native cursors

A measurement on Windows confirmed what the engine source implied: of
500 cursors made by CreateIconIndirect, DeleteObject freed none and
left 500 USER objects alive, while DestroyCursor and DestroyIcon freed
all 500. The comment now says so, that native cursors have no LRU for
that reason only, and that one can come back once both engines destroy
cursors.

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

* cursor: look a native cursor up by the raster asked for

The key ended in the raster made last. A shape switched away from keeps
no pixels, so its raster stays where it was, and going back to a raster
it was shown at before looked up the wrong key: A at 1.0, A at 0.5, then
B, then A at 1.0 again missed the 32x32 cursor that was still there,
fetched A from the core, and showed B meanwhile. With native cursors
kept for every raster, the key now ends in the raster asked for; with
pixels, that is the raster made, as before.

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

* cursor: let a shape keep its pixels until a native cursor holds them

A shape switched away from, or decoded after the peer moved on, let its
pixels go whether or not it had a native cursor, and a native cursor is
made only for the shape in use. A shape whose restore kept finishing
after the next switch, as an animation's frames can, was fetched and
decoded again every time it came back and never got a native cursor.

A shape without one now keeps its pixels until one is made, at most
kRecentShapes of them, the one waiting longest let go first. Mobile
only paints the cursor, so it lets them go as before.

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

* proto: keep cursor ids as strings in JS

A Windows peer before 1.5.0 names a cursor by its handle, sign-extended
above 2^53 when its top bit is set. The web client decodes u64 fields
into JS numbers, and ts-proto throws on a value a number cannot hold,
so each such cursor_data or cursor_id was dropped whole and the web
cursor stayed on the shape before. [jstype = JS_STRING] makes ts-proto
generate these two fields as strings; the wire format, the Rust code
generated and every other client are unchanged.

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

* cursor: wait for a native cursor at the raster asked for, not any raster

A shape let its pixels go once it had a native cursor at some raster.
After a zoom, or on a display of another scale, an animation's frames
had native cursors at the old raster only; each was fetched again at
the new one, and a restore that finished after the next switch let its
pixels go at once, so the frames never got a native cursor at the new
raster and the cursor stayed on the one shown before. A lookup that
finds no native cursor at the raster asked for now counts the shape as
waiting for one, so a late restore keeps its pixels until it has one.

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

* cursor: count a shape held again when its asked raster has a native cursor

A lookup that missed took the shape out of _nativeIds, but one that hit
did not put it back. A shape asked at a new raster, then at one it has a
native cursor for, still counted as waiting: a restore finishing after
the switch away kept its pixels in _awaitingNative instead of letting
them go. The set now says whether the raster asked last has a native
cursor, either way.

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

* Revert "cursor: count a shape held again when its asked raster has a native cursor"

This reverts commit 7cefbfc7a.

_nativeIds is meant as "no raster of this shape is still missing its
native cursor", not "the raster asked last has one". A shape asked at a
new raster whose restore is on its way, then shown at a raster it has a
cursor for, still waits for the new one; counting it held let the late
restore's pixels go, so going back to the new raster fetched it again,
and with an animation's frames and a scale that keeps changing, the
loop 1e4388771 fixed could come back. Keeping the pixels costs at most
a waiting slot, within the 64. A test now locks this: a raster still
missing keeps a late restore whatever was shown meanwhile.

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

* cursor: reuse a shape sent before without compressing it again

A handle not seen before sent the service through the whole path:
capture the pixels, name them by content, compress them, and only then
find the shape already in CURSOR_SHAPES and drop what it compressed. An
app that mints a new handle for the same cursor, as Chrome and Electron
do, paid a compress per handle, a few ms for an enlarged cursor. The
content id is now looked up first, and only a new shape is compressed.

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

* cursor: bound the handles the service files, and the shapes with them

A platform may mint a new handle each time it shows a shape, as Chrome
and Electron do, so cached_cursor_data grew by an entry per handle for
the life of the service, even when every one named the same shape; and
the compressed shapes in CURSOR_SHAPES were bounded by nothing but the
handles. Past 4096 handles, or 32 MiB of compressed shapes however few
the handles, both start over together, keeping the shape being sent: a
handle shown again is captured and named again. On every platform; the
DRM build's own 64-entry ceiling is unchanged and comes first there.

CURSOR_SHAPES keeps the bytes it holds beside the shapes, cleared with
them, so every path that clears it resets the count.

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

* cursor: let the macOS seed report a change, and the content name the cursor

macOS named a cursor by a fingerprint of its size, hotspot and two
pixels near the hotspot. It collides between standard cursors: the open
and the closed hand give one value, and the arrows with a badge (copy,
not allowed, contextual menu, disappearing item) another, so a switch
between them was never sent, or a cached shape of the other was.
CGSCurrentCursorSeed already says when the cursor changed; get_cursor
now reports the seed, and the shape is named by its content, as on
every platform. The fingerprint is gone.

get_cursor_data checks the seed before and after the capture. The seed
was taken before capturing, so a capture that found the cursor changed
again lost the change; it now leaves it for the next poll.

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

* cursor: file no macOS seeds, and send nothing for the shape already shown

A macOS seed marks a change and never comes back, so filing it as a
handle only filled the map; the byte ceiling still bounds the shapes
there. A new handle or seed for the shape already shown, which a seed
change without a new shape or an app minting handles gives, sent every
connection a cursor_id for what it already showed; a shape is one
message however many handles name it, so the same message is not sent
again.

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

* cursor: capture the macOS cursor shown without checking the seed around it

Every macOS seed change is now captured, pixel by pixel, and a seed
that moved during that capture failed it; the service then logged an
error and slept a second, so the controller's cursor lagged while it
changed. The check guarded nothing: the shape is named by its content,
not the seed, and a change after get_cursor read the seed moves it on,
so the next poll captures it anyway. Resetting the seed on a failed
check was not needed for the same reason.

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

* cursor: fall back to the default cursor for a shape the core cannot give

The shape shown before stays while the one in use decodes, so a switch
does not flash the default cursor. It also stayed once the shape in use
was known to be lost (rejected by the core, or not decoding): the
controller kept drawing, say, an I-beam for a pointer it never got,
until the peer changed shape. Both the native and the painted cursor
now leave the default one, as master did for the native cursor. A shape
that does not decode is marked in updateCursorData, whether it was sent
or fetched again, so both paths agree.

The client also selects a shape it rejects as it arrives. It showed the
shape before until the peer selected the rejected one again, and the
default cursor from then on; it now looks the same both times. Sciter
ignores an id it has no shape for, as it did.

The default and forbidden cursors are made with the model. They were
made the first time a build asked for one, and their decode lands
later with nothing to draw again, so that first build showed the shape
before in their place.

A fetch failing after the session was cleared marks nothing in the new
one, and a fetch that throws at once tells the listeners after the
build that asked for it, not during it.

The forbidden cursor, shown while input is off, became the cursor shown
last, so a raster made after input came back showed it until the
shape's pixels were fetched, or for good if they never came. Only a
shape stands in for a shape now; CursorModel.shown keeps the rule for
both builders.

_checkView asks for a single registration again: every native cursor
is made once per session, and one made twice leaks an HCURSOR on
Windows.

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

* cursor: name a handle by the shape rejected under it, not the one before

A shape the client rejects is selected as it arrives, so that it shows
the default cursor then as it does later. Under a handle reused for it,
the dedupe still named the shape the handle had brought before, and
selecting that switched the pointer to an old shape: A under handle 7,
then B under 8, then a rejected C under 7 showed A in place of B, and
again whenever the peer selected 7.

The handle now names the rejected content, which is never marked
decoded, since only a shape not decoded before reaches the decode: the
UI has no shape under it and shows the default cursor, then and when
the handle is selected again. The shape the handle named before stays
kept for the other handles naming it, and a handle that brings it again
names it again.

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

* cursor: file no handle for a shape that is rejected under it

A shape rejected under a handle named the handle by the rejected
content, so that a known handle does not show the shape it brought
before. A handle never seen was filed the same way, so a peer sending
shapes that do not decode under new handles grew the map with each,
where a rejected shape used to leave nothing. Only a known handle is
renamed now; one never seen is not filed and is passed on as unknown,
which the UI shows as the default cursor.

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

* cursor: say what Future.sync defers, and give the default cursor time on a slow runner

The comment on the fetch in restorePixels read as if the fetch were put
off; it runs at once, and Future.sync only turns its throw into an error
the await hands over later, which is what keeps the notify out of the
build.

cursor_default_test gave the default cursor 100 ms to decode, which a
slow runner may miss. It cannot wait for the cursor itself, since asking
for it would make it; it now waits 500 ms.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:09:46 +08:00