Commit Graph
352 Commits
Author SHA1 Message Date
fufesou eab27cc249 Build Windows custom client packers in CI (#16781)
* Build Windows custom client packers in CI

Verify payload signatures, build templates for all three Windows architectures, and include the packers in the unsigned release archive.

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

* fix(ci): preserve unsigned Windows releases without signing

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

* fix(ci): align template packer DPI manifests

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-10-31 15:40:32 +08:00
RustDeskandClaude Opus 5.5 b1761ee676 Ci run all dart tests (#16328)
* ci: run every Dart test, not three files

The step ran only the three cursor test files, so the other nine files
under flutter/test (terminal, file model, autocomplete, ...) never ran
on any pull request. With no path the runner takes the whole directory.

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

* ci: run the Rust tests CI could not reach

cargo test --workspace runs with the default features on x86_64 Linux
only, so the tests behind `windows`, `not(target_os = "linux")`,
`unix-file-copy-paste` and drmtap_dl's `tests` module never ran: the
platform, portable-service and audio tests, the clipboard crate's file
copy-paste and Windows file-name tests, and the drmtap loader's.

The macOS and Windows build jobs now run `cargo test --lib` after the
build with the build's own feature set, plus the clipboard crate. The
Linux job's list of named test runs gains drmtap_dl and the clipboard
feature. AGENTS.md says where tests go and what CI runs.

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

* ci: drop the skips for tests that no longer exist

#16539 removed test_get_cursor_pos and test_get_key_state, the only
tests these skips matched.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 16:31:58 +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
rustdesk 0b5a3d74de 1.5.1 2026-10-12 17:08:40 +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
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
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
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
RustDeskandClaude Opus 5.5 483cc10266 fix(appimage): build AppRun with bounded argument splitting (#16330)
* fix(appimage): build AppRun with bounded argument splitting

AppRun v2.0.0, which appimage-builder downloads, copies each argument
into a 1 KiB stack buffer in apprun_shell_split_arguments, so any
argument of 1024 bytes or more aborts the AppImage with "buffer overflow
detected" before RustDesk starts (e.g. --connect with a 10000-char id).

Build AppRun from the v2.0.0 commit with a patch that sizes the buffer
from the input and stops the quote/escape scanning at the terminating
NUL, in ubuntu:16.04 like upstream's release, and put it where
appimage-builder looks before downloading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(appimage): verify the patched AppRun is the one shipped

Reset the split buffer by its first byte rather than zeroing all of it
per argument, check the AppRun source tarball's sha256, pin the build
image by digest, and fail the job if appimage-builder deployed anything
other than the binary built here.

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-25 00:49:00 +08:00
fufesouandMariano Abad 12f61b1fda Fix/drm rotated cursor (#16242)
* fix(drm): turn the cursor by the output's real angle, and guess its hotspot upright

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

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

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

(cherry picked from commit 6a73100b4783fbf9c8f43e0dc7651b7568db9ea9)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Mariano Abad <weimaraner@gmail.com>
2026-09-22 15:18:10 +08:00
5278fcab68 fix(cursor): correct native cursor sizing and validate received images (#16213)
* fix(flutter): shrink the unzoomed remote cursor by DPR on macOS and Linux

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(cursor): preserve mixed alpha during downsampling

* comments

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

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

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

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

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

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

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

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

* fix: align Wayland software scrolling with input coordinates

* fix: preserve scroll offsets when switching scrolling modes

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

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

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

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

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

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

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

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

* fix: cursor size test

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

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

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

* fix(cursor): limit received cursor allocations

* test(cursor): retain reverse raster transition coverage

* fix(cursor): bound compressed cursor input

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: rustdesk <info@rustdesk.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:43:07 +08:00
fufesou bf1ebe5be2 Ci/native arm64 msbuild (#16170)
* ci: use native ARM64 MSBuild for MSI packaging

* ci: clarify MSBuild host and MSI target architectures
2026-09-12 14:31:16 +08:00
fufesou e54f21e10c Fix/ci (#16168)
* fix(ci): allow native x86 Rust toolchain on Windows

* docs(ci): explain the rustup 1.29.1 host check

* docs(ci): explain the unverified x64 cross-compilation alternative
2026-09-12 11:33:49 +08:00
rustdesk 5cfe136fb0 fix mac sign 2026-09-10 12:08:47 +08:00
Sid 68359a2dd2 fix(macOS): preserve release entitlements when signing (#16125)
* fix(macOS): preserve release entitlements during signing

Signed-off-by: Sidn <3996515+sidnvy@users.noreply.github.com>

* ci(macOS): sign outer app with release entitlements

Signed-off-by: Sidn <3996515+sidnvy@users.noreply.github.com>

---------

Signed-off-by: Sidn <3996515+sidnvy@users.noreply.github.com>
2026-09-09 14:48:59 +08:00
rustdeskandClaude Opus 5 254d98129d ci: use thin LTO for the armv7 sciter build
The nightly armv7 sciter build aborts while compiling the final `rustdesk`
binary:

    fatal runtime error: Rust cannot catch foreign exceptions
    error: could not compile `rustdesk` (bin "rustdesk")  (signal: 6, SIGABRT)

armv7 is the only 32-bit target in this job that links the whole binary, and
`[profile.release]` uses fat LTO with codegen-units = 1, so LLVM merges every
module into a single unit and runs past the ~3GB address space a 32-bit
process gets. The allocation failure surfaces as a C++ bad_alloc unwinding
into rustc's Rust frames, which is what that "foreign exceptions" abort is.
The x86_64 sciter build uses the same settings and passes, as do aarch64 and
every other 64-bit job in the same run, so this is specific to the 32-bit
address space rather than to the source change itself.

It started failing once the crate graph grew (hbb_common is pulled with the
`webrtc` feature unconditionally), which pushed an already marginal target
over the limit.

Use thin LTO with more codegen units for armv7 only. Peak memory stays
bounded while cross-crate inlining is kept, and 64-bit targets are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lq6xFoeEmjcuKwRx1GfdQ2
2026-09-06 10:52:37 +08:00
fufesou ba6de7990f fix(ci): ubuntu-22.04-arm, oom (#16056)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-04 15:16:33 +08:00
fufesou 82aa28f129 fix(ci): install CMake 4.3 for ARM64 vcpkg builds (#16044)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-03 19:28:54 +08:00
0fd1a0eecb Custom client no rebuild (#15774)
* feat(portable): load per-customer payload from a PE resource

Customizing a Windows client recompiled the packer for every customer,
because data.bin was baked in with include_bytes!. The generic payload is
identical across customers, so only the small per-customer delta needs to
vary: the branded runner exe, custom.txt and the icons.

The packer now also reads an RDPKG RCDATA resource holding a second blob in
the same format, and folds it over the compiled-in payload. A build can then
inject that resource into a prebuilt template instead of running cargo.

The executable to launch comes from the package trailer, and the extraction
directory follows its stem, which replaces the sed of APP_PREFIX. Where the
executable itself is not customized (sciter x86) it stays in the generic
payload and is only renamed, so the merge covers both shapes.

custom.txt keeps being written to disk next to the app: that is what the
client reads at startup and what the updater stages so a customization
survives an upgrade to a stock build.

Also fixes generate.py restoring os.curdir (the literal ".") instead of the
previous working directory, which left it inside the source folder.

CI: ship windows-aarch64 in the unsigned tarball, so ARM custom clients have
a template to build from.

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

* ci: publish msi templates for custom client builds

Custom clients rebuild the msi through WiX for every customer, though the
package only differs by the app name, a few GUIDs and four files.

Build the msi once more per release with a __RDAPPNAME__ placeholder and ship
it unsigned in the unsigned tarball, so a customer's build can patch it rather
than run msbuild. It stays unsigned because patching would invalidate a
signature anyway.

Doing this in CI is what makes ARM custom clients possible: preprocess.py runs
the packaged exe to read its version and build date, so an arm64 msi can only
be produced on a native arm64 machine, which the runner already is and the
build agents are not. Patching runs no exe, so an x64 agent can then patch the
arm64 template.

preprocess.py rewrites res/msi in place and locates the app as <app-name>.exe
inside the dist, so the tree is reset around the second build and the dist copy
is renamed to match. Sciter x86 ships no msi and is untouched.

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

* refactor(msi): pass the app name to the printer custom actions

preprocess.py rewrote the CustomActions sources per customer so the printer
carried the app name, which meant the dll was recompiled for every custom
client and, worse, left the app name baked into a compiled binary.

Pass it through CustomActionData instead. Only the printer and its port ever
varied: the INF path and the driver name ship under their stock names and
preprocess.py already forced the driver name back to RustDesk, so a single
build of the dll now serves every custom client.

Both actions treat the name as optional and fall back to the stock name, so a
package built before this still installs and uninstalls its printer.

This also unblocks patching a prebuilt msi template, which cannot work while a
compiled dll contains the app name: replacing a string inside a PE would shift
everything after it.

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

* ci: use an 8.3-safe placeholder for the msi template

WiX derives a short name for any name that is not valid 8.3, and a patch
cannot rewrite a truncated placeholder, so a long placeholder would leave the
package's short names pointing at it. RDAPPNAM is eight characters like
"RustDesk" and needs no short name, keeping the template as close to the
shipped package as the mechanism allows.

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

* feat(msi): give a template its own cabinet for per-customer files

Rebranding recompressed the whole ~100MB payload because one cabinet held
everything. In template mode preprocess.py puts the handful of files a custom
client replaces on a second cabinet, so a patch rebuilds a few hundred KB and
leaves the payload cabinet alone. The shipped msi is built without template
mode and keeps its single cabinet.

The branding assets need conditional components. A stock build ships none of
them -- there is no icon.ico, icon.png or logo*.png, only icon.svg -- so the
template has to carry placeholders for the File rows to exist, and a customer
supplies whichever they want. Installing a placeholder unconditionally would
give a customer with no logo a placeholder image, where today a missing asset
means no logo at all: the client tries each candidate and treats the failure as
absence. So each optional asset installs only when its property says the
customer supplied one.

CI creates those placeholders and builds the template with the new mode.

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

* ci: build the msi template with a sentinel revision

preprocess.py appends a build-time revision as the fourth version field, so a
template built without one would bake the CI clock into every customer's
package. Revision 0 marks the field as the patcher's to fill in, and makes the
template deterministic.

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

* fix(portable): delete files a later package no longer carries

The extraction directory is wiped only when the packer's compiled-in timestamp
changes. That used to be per customer, because generate.py ran for each build;
now the packer is compiled once per release, so every customer and every
rebuild within a release share one timestamp and nothing is ever wiped.

A customer who removes their logo and rebuilds would therefore keep showing it:
the new package simply omits logo.png, and md5 skipping only covers files that
are still present. Record the package's paths in the extraction's meta file and
delete the ones a later package drops.

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

* fix(portable): build the dropped-file path from plain components

meta.toml lives in a user-writable directory and now drives deletion, but the
traversal guard tested the normalised string while the join used the raw one.
Path::join replaces the base outright when handed an absolute path, so an
edited meta.toml could point remove_file anywhere.

The path is now rebuilt from Normal components only. A colon is rejected
explicitly rather than left to the host's parser: a drive-relative "C:x" parses
as a Normal component everywhere, and only a Windows host reads "C:/..." as a
prefix, so the same input escaped when the logic was exercised off-Windows --
which is what the new test catches.

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

* fix(msi): pass the printer name in a format the custom action can read

[~] is MSI's escape for a NUL character, not the delimiter WcaReadStringFromCaData
splits on -- that is a literal wide char 128, which a Formatted property value
cannot carry -- and WcaGetProperty returns a null-terminated string anyway. So
the second field was unreachable: InstallPrinter always fell back to the stock
name and installed a printer and port called "RustDesk Printer" inside a
customer's branded package, while UninstallPrinter, whose data is a single field
and parsed fine, went looking for "Acme Printer" and left the real one behind
for good.

Both actions now read CustomActionData directly and split on a character that
cannot occur in a Windows path or in a validated app name. A package built
before this carries no separator and keeps the stock name, as it did.

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

* fix(portable): retry failed stale branding cleanup

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

* fix(portable): reject malformed RDPKG resources

Distinguish an absent customer package from an invalid resource and
propagate package errors instead of launching the stock payload.

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

* refact: format 2 files

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

* fix(msi): match process names case-insensitively during uninstall

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

* fix(custom-client): validate portable exclusion and MSI action data

Fail when --exclude-exe does not match a file, and propagate MSI
CustomActionData read failures while preserving legacy fallback behavior.

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

* fix: generate.py, exclude-exe

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

* Revert "fix: generate.py, exclude-exe"

This reverts commit 5104664e95.

* fix: simple path fix in generate.py

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

* Remove useless comments

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

* fix(portable): remove expect() anyway

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

* fix(portable): validate executable path boundaries

Reject executables outside the source folder and
reuse the package path normalization logic during
stale file cleanup.

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

* fix, remove useless file

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-09-02 22:14:03 +08:00
rustdesk 28cf1836e6 bump to 1.5.0 2026-09-01 08:57:33 +08:00
rustdesk 2c84c8fb13 change to 3.44.9 flutter for arm 2026-08-31 19:06:32 +08:00
Kino cec4085238 Bump aom to v3.14.1 (#15883)
* Bump aom to v3.14.1

* Remove oboe dependency in vcpkg.json
2026-08-25 19:53:56 +08:00
fufesou b0008edcb5 refact: remove linux headless (#15866)
* refact: remove linux headless

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

* fix(linux): probe DRM availability asynchronously on login

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

* revert changes in drm_capturer.rs

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

* Update submodule hbb_common

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

* docs(linux): clarify DRM availability comments

Remove stale headless and unauthenticated-request
wording, and document the Available-only login-screen gate.

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

* fix(linux): remove unreachable session cleanup branch

Remove the obsolete empty-session path and
clarify the intended use of cached DRM availability.

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-18 15:02:50 +08:00
fufesou edd0e5fbd4 fix(CI): rust 1.75, linux sciter (#15874)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-17 09:30:19 +08:00
4234b99029 WebClient: 3.44 webcodecs offline (#15722)
* feat(web): zero-readback WebCodecs video path

Decoded VideoFrames from js/src/webcodecs.js are handed to Flutter via
window.onVideoFrame and imported GPU-side with createImageFromTextureSource;
any failure unregisters the hook so the JS side falls back to RGBA readback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): load bundled terminal font when Google CDNs are unreachable

In air-gapped deployments GoogleFonts.robotoMono() cannot download the
terminal font; when index.html signals offline mode, load the copy bundled
with the web app under the family name google_fonts registers.

Part of the fix for rustdesk/rustdesk-server-pro#996.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: bump windows arm64 to Flutter 3.44.8, add web build patch script

apply_flutter_3.44_web_patches.sh prepares a 3.44.x web build on top of the
shared source patches: qr_code_scanner's web impl needs dart:ui_web for the
removed platformViewRegistry, and flutter/web/fonts is refreshed to the font
paths the 3.44 engine requests. The disabled build-rustdesk-web job runs it
automatically once FLUTTER_VERSION moves to 3.44.x, and version-guarded
'Patch flutter' steps no longer fail when the guard does not match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): prevent stale WebCodecs frames across sessions

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

* fix(web): harden WebCodecs reconnect and Flutter 3.44 patches

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

* fix(ci): harden Flutter 3.44 patch input validation

Validate required files before checking patch state,
parameterize the theme-range validator, and prevent
missing inputs from satisfying NO_MATCHES checks.

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

* Remove unused code

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

* fix(web): retry font loading and dispose stale decoded images

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

* remove unused code

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

* fix(web): Bad state: RenderBox was not laid out

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-07 15:20:57 +08:00
RustDeskandClaude Opus 5 9a81c8a138 Drm deb in release workflow (#15776)
* docs(agents): add a comment-length rule

Comments were growing to document rejected alternatives, past bugs and
measurements. That belongs in the commit message, not the source.

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

* ci(drm): build the unattended-wayland deb in the release workflow

The deb was built by a separate drm-capture workflow on a plain runner,
so it diverged from every other Linux deb: different base, different
vcpkg/ffmpeg, different toolchain. Move it into flutter-build.yml as
build-rustdesk-linux-drm, mirroring build-rustdesk-linux's x86_64 path --
same ubuntu18.04 container, same vcpkg install, same rust and flutter.
libdrmtap is built on the runner first and handed to the container via
DRMTAP_PREBUILT_DIR, because bionic's meson is too old to build it.

The job is ungated, so the --drm packaging path is exercised on every PR;
only publishing stays gated on upload-artifact. drm-capture.yml is
deleted along with docs/DRM_CAPTURE_SECURITY.md -- the 29 drm unit tests
that workflow ran are no longer executed by CI.

Three bugs the move exposed:

- build.py anchored the libdrmtap paths on abspath(__file__), which is
  only cwd-independent on Python >= 3.9 (bpo-20443). The packaging
  container runs 3.6 and chdir's into flutter/, so the ABI-gate
  cross-check resolved one directory off and every --drm packaging run
  would have died with FileNotFoundError. Captured as REPO_ROOT at
  import instead.
- DRMTAP_PREBUILT_DIR no longer needs DRMTAP_ALLOW_UNPINNED. A prebuilt
  dir inside the repo's own third_party/libdrmtap at the pinned sha is
  the pinned object, not an override, and is now verified as such.
- The variant's Depends carried a bare libdrm2. libdrmtap needs
  drmModeGetFB2, so it is libdrm2 (>= 2.4.95); below that the package
  installed and could never capture.

The loader also logs the dlerror now instead of discarding it, so a
soname or glibc mismatch is named rather than surfacing as a generic
"libdrmtap not available".

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

* fix(drm): declare the unattended-wayland deb's real libc6 and libdrm floors

libdrmtap is built on the ubuntu-22.04 runner while the rest of the deb comes
from the ubuntu18.04 container, so the package has a mixed glibc floor and
declared neither half. It installed happily on Ubuntu 20.04 / Debian 11
(glibc 2.31), then dlopen failed on GLIBC_2.34 and capture degraded to the
PipeWire portal -- the one thing this variant exists to avoid. Measure the
floor off the staged objects and put it in Depends, so apt refuses with a
reason instead of handing over a package that can never capture.

Measured rather than written down: the number moves whenever either base does,
and it lands exactly on RHEL/Rocky 9 (glibc 2.34), where one off-by-one decides
whether that whole family can install.

drmModeGetFB2 landed in libdrm 2.4.101, not 2.4.95 -- checked against the
libdrm tags, xf86drmMode.h first declares it in 2.4.101. The old floor admitted
Debian 10 (2.4.97), where the .so is linked -z now and dies on an undefined
symbol at dlopen. libdrmtap's own meson.build carries the same wrong number.

Upload the deb on always(): the run that fails the drm check is the one whose
artifact is most worth downloading. Publish stays gated on success, so an
unverified build still cannot reach a release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:31:09 +08:00
Daniel Marschallandgreptile-apps[bot] b19f1ef76f Add SBOM (Software Bill of Materials) for the EU Cyber Resilience Act (EU CRA) (#15732)
* Update flutter-build.yml to generate SBOM

* SBOM Generation: Also checkout submodules

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-03 15:17:05 +08:00
RustDesk 12b5cc7f72 Revert "fix: ci: macos: allow signed but not notarized dmg (#15530)" (#15551)
This reverts commit 29e1852a68.
2026-07-10 11:47:13 +08:00
Zhenyu FU 29e1852a68 fix: ci: macos: allow signed but not notarized dmg (#15530)
* fix: ci: macos: allow signed but not notarized dmg
Signed-off-by: Zhenyu FU <ysfcore@outlook.com>

* fix: ci: macos: add pre-check for macos identity
Signed-off-by: Zhenyu FU <ysfcore@outlook.com>

* merge notarize checking to existing steps
Signed-off-by: Zhenyu FU <ysfcore@outlook.com>
2026-07-09 15:23:43 +08:00
rustdesk 6c578292e8 bump to 1.4.9 2026-07-06 18:00:39 +08:00
twprh 10d5250d23 Update flutter-build.yml (#15454) 2026-06-28 17:18:41 +08:00
fufesou 30c0867e40 fix(ci): win arm64 (#15333)
* fix(ci): win arm64

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

* fix: ci, less changes

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-19 00:10:37 +08:00
8f50ea64dc Add Windows arm64 support (#15139)
* Add initial arm64 build logic

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>

* Upgrade Flutter to 3.44.0 and introduce Windows arm64 in CI

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>

* Bump bridge build to Flutter 3.44 as well

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>

* Fix install flutter step for Win arm64

* Bump install-llvm-action to v2 for arm64 support

* Fix libsodium logic to only install through vcpkg on win arm64

* Fix Flutter installations on Win

* Flutter XCode: only build the current arch as it defaults to universal

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>

* Ensure that we really have arm64 Dart + Flutter engine in CI

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>

* Enable hwcodec feature now that upstream supports building it

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>

* CI: improve logic for getting Flutter arm64 SDK

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>

* Apply PR feedback (only bump Flutter version on Win arm64)

* Exclude MSI build on arm64

* CI: build the MSI for Windows arm64 (WiX v4 ARM64 platform + native CustomActions)

* Address PR feedback

* Update Cargo.toml

* Update Cargo.lock

* Update Cargo.lock

* Add Flutter 3.44 DialogThemeData background colors

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

---------

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>
Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-06-18 22:37:15 +08:00
rustdesk 8baa995c7a bump version 2026-06-17 22:18:45 +08:00
rustdesk 50d5823ef5 1.4.7 2026-06-02 17:06:23 +08:00
rustdesk 81e7d27ec8 pin more action 2026-05-26 19:03:03 +08:00
rustdesk f3fc0b5ac2 pin unpinned action 2026-05-26 18:08:28 +08:00
rustdesk fb7bca436b fix ci 2026-05-26 17:08:32 +08:00
rustdesk c19a0ceba2 more "cargo build --locked" 2026-05-26 11:45:15 +08:00
Vasyl GelloandRustDesk 682e347be0 Bump Android NDK to r28c (#13685)
Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-03-12 16:52:33 +08:00
rustdesk d49ae493b2 bump to 1.4.6 2026-02-27 20:53:40 +08:00
Sunev b0c12bd86b Update signing conditions for rustdesk files (#14010)
Now ```env.SIGN_BASE_URL``` would never be ```''```, yet could be ```'-2'``` while ```secrets.SIGN_BASE_URL``` was undefined.
2026-01-10 15:29:59 +08:00
fufesou 998b75856d feat: Add relative mouse mode (#13928)
* feat: Add relative mouse mode

- Add "Relative Mouse Mode" toggle in desktop toolbar and bind to InputModel
- Implement relative mouse movement path: Flutter pointer deltas -> `type: move_relative` -> new `MOUSE_TYPE_MOVE_RELATIVE` in Rust
- In server input service, simulate relative movement via Enigo and keep latest cursor position in sync
- Track pointer-lock center in Flutter (local widget + screen coordinates) and re-center OS cursor after each relative move
- Update pointer-lock center on window move/resize/restore/maximize and when remote display geometry changes
- Hide local cursor when relative mouse mode is active (both Flutter cursor and OS cursor), restore on leave/disable
- On Windows, clip OS cursor to the window rect while in relative mode and release clip when leaving/turning off
- Implement platform helpers: `get_cursor_pos`, `set_cursor_pos`, `show_cursor`, `clip_cursor` (no-op clip/hide on Linux for now)
- Add keyboard shortcut Ctrl+Alt+Shift+M to toggle relative mode (enabled by default, works on all platforms)
- Remove `enable-relative-mouse-shortcut` config option - shortcut is now always available when keyboard permission is granted
- Handle window blur/focus/minimize events to properly release/restore cursor constraints
- Add MOUSE_TYPE_MASK constant and unit tests for mouse event constants

Note: Relative mouse mode state is NOT persisted to config (session-only).
Note: On Linux, show_cursor and clip_cursor are no-ops; cursor hiding is handled by Flutter side.

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

* feat(mouse): relative mouse mode, exit hint

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

* refact(relative mouse): shortcut

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-09 10:03:14 +08:00
YuZhiYuanDev 84eb75d5b6 ci: update macOS runner from unsupported macos-13 to macos-latest (#13855)
- Replace deprecated `macos-13` with `macos-latest` runner
- Ensure CI compatibility with supported macOS versions
- Maintain build stability and future-proof workflows
2025-12-20 21:21:14 +08:00
RustDesk 692e90f779 Update flutter-build.yml (#13817) 2025-12-16 10:33:50 +08:00
RustDesk e4faedcb62 Update flutter-build.yml (#13815) 2025-12-15 19:51:48 +08:00
RustDesk da2c678fb3 Revert "Disable signing commands in flutter-build.yml (#13750)" (#13808)
This reverts commit 822b6d1baf.
2025-12-14 17:41:18 +08:00
fufesou 0112b3387e fix(CI): macOS, nasm, use 2.16.x (#13781)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-11 20:16:06 +08:00
YuZhiYuanDev 735862d1fd Replace unsupported macos-13 with a new runner (#13767) 2025-12-10 17:05:52 +08:00
RustDesk 822b6d1baf Disable signing commands in flutter-build.yml (#13750)
Comment out signing commands in the Flutter build workflow.
2025-12-09 00:07:11 +08:00