Compare commits

..

20 Commits

Author SHA1 Message Date
rustdesk
2637003859 temporary password: rotate when a peer is let in, not when it leaves
The one-time password was regenerated after the connection loop exited,
so a remote desktop session kept it valid for hours and a port-forward
tunnel for as long as its mapping lived. It now rotates the moment a
connection becomes authorized. Reconnects and windows opened from a
live session are unaffected: they log in on the password the session
remembers, for 30 seconds past its last activity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-05 14:20:25 +08:00
fufesou
618bf37deb feat(terminal): add opt-in OSC 52 clipboard writes (#16072)
* feat(terminal): add opt-in OSC 52 clipboard writes

* Remove dup tr

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-05 13:21:49 +08:00
RustDesk
c1a587cfa4 connection page: the Connect menu offers TCP tunneling, as the peer card does (#16075)
The dropdown beside Connect listed file transfer, camera and terminal
but not port forwarding, so reaching it meant having a card for the
peer. `connect` already takes `isTcpTunneling`; only the menu entry and
the parameter that carries it were missing.

Shown on desktop only. The peer card gates the same entry on `isDesktop`
because `connect` routes a tunnel through the desktop path alone; on web
it would have opened a plain remote session instead.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 01:28:27 +08:00
RustDesk
9a1c8da143 Agents regression surface (#16070)
* AGENTS.md: require a regression-surface check before a change is done

The minimal-invasiveness rules say what to prefer; nothing made an
agent check the final diff against them, so a feature could still route
the old path through its new code while every principle was "followed".
This adds the gate: audit every modified existing path, keep feature-off
on the old code, report the regression surface, and treat an
unnecessarily rewritten legacy path as a review finding whatever the
tests say.

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

* AGENTS.md: a scope check before shared code is touched

The minimal-invasiveness rules are principles; this adds the stop
condition that makes them mechanical. A fix for one path stays in that
path, and an unrelated caller needing a placeholder argument to satisfy
a changed signature is the signal that it did not.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:50:43 +08:00
RustDesk
978c901f49 port forward: a login's target and challenge travel with its own accept (#16069)
* port forward: mappings take turns at the window's login slots

A window's mappings log in concurrently, and each login is built from
the shared `LoginConfigHandler`: `create_login_msg` reads
`port_forward`, which `listen()` set before connecting, and
`handle_login_from_ui` reads `hash`, which the last `Hash` to arrive
set. Two mappings logging in at once could swap targets, bridging a
local socket to the other's target, and answer each other's challenge,
failing one login. The window's password prompt is broadcast to every
mapping, so one whose `Hash` had not arrived answered with whatever the
handler held.

Each mapping now fills `port_forward` and `hash` and sends its login
under a per-window turn lock, and keeps its own `Hash` beside the
connection: a password typed before it arrived is left to the mapping
that prompted, which stores the salted password in the shared handler
for the others to log in with.

The fix stays in `port_forward.rs`. `LoginConfigHandler` gains the lock
and a setter for its private `hash`; `Interface`, `Session` and the
login functions keep their signatures.

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

* port forward: the lock and the hash setter are crate-private; test the hash that arrives late on its real path

Both exist only so `port_forward.rs` can reach the handler's private
`hash`; neither is API.

The test for a password typed before a connection's hash ended by
answering the prompt again once the hash was there. What happens in the
code is that the hash's arrival runs `handle_hash`, which logs in with
the password the prompting mapping stored; the test now ends there, with
no preset password. Answering the prompt with one's own challenge while
the handler holds another's is a test of its own.

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

* port forward: a password typed before the connection's hash answers it when it comes

The previous commit dropped such a password, counting on the mapping
that prompted having stored it in the shared handler by the time this
connection's `Hash` arrived. The broadcast wakes both mappings at once
and `select!` picks between a ready `Hash` and a ready password at
random, so this one could reach `handle_hash` first, find the handler
empty, and prompt again.

The connection keeps the password until its `Hash` arrives and answers
with it then. `login_from_ui` takes the challenge it answers; the wait
is `connect_and_login`'s, in `hash_arrived`.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:39:09 +08:00
RustDesk
d453a19601 AGENTS.md: require a regression-surface check before a change is done (#16068)
The minimal-invasiveness rules say what to prefer; nothing made an
agent check the final diff against them, so a feature could still route
the old path through its new code while every principle was "followed".
This adds the gate: audit every modified existing path, keep feature-off
on the old code, report the regression surface, and treat an
unnecessarily rewritten legacy path as a review finding whatever the
tests say.


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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:31:31 +08:00
RustDesk
50c4e435de connection: apply the non-video send timeout once the type is known (#16063)
* connection: apply the non-video send timeout once the type is known

`Connection::start` set the send timeout before the login request had
arrived, when `file_transfer`, `port_forward_socket` and `terminal` were
all still unset, so every connection got `SEND_TIMEOUT_VIDEO` (12 s) and
the `SEND_TIMEOUT_OTHER` branch never ran. A file transfer, terminal or
port forward whose peer stopped draining for 12 s — a Wi-Fi roam, a VPN
reconnect — was dropped.

The type-specific timeout is now set in `on_message` right after the
login request's union has been matched; `start` keeps the video figure
for the login phase.

`SEND_TIMEOUT_OTHER` also drops from 120 s to 30 s, the same horizon as
the 30 s read timeout: the timeout wraps a single `send`, so it only
fires when the peer makes no progress at all for that long, and beyond
30 s the read check would declare the same peer dead anyway. The raw
port-forward pipe's write to its local target shares the constant and
moves with it.

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

* connection: keep the raw port-forward local write at 120 s

`SEND_TIMEOUT_OTHER` also bounded `forward.send` in
`try_port_forward_loop`, the write to the local target, whose own idle
timeout is an hour. Lowering it to 30 s made a target that stops
draining for half a minute drop the whole tunnel. That write gets its
own constant at the value it always had.

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

* connection: keep the non-video send timeout at its long-standing 120 s

Lowering `SEND_TIMEOUT_OTHER` to 30 s was a policy change on top of the
bug fix, argued from the 30 s read timeout, which measures something
else and cannot even run while a send is blocked. The constant goes
back to `SEND_TIMEOUT_VIDEO * 10`, where it has been since 2021, and
the raw port-forward loop's local write shares it again. What remains
is the fix alone: the type-specific timeout is chosen once the login
request has said what the connection is.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:12:24 +08:00
fufesou
d5c6d0f6b7 fix(custom client): msi update, preserve exe name (#16057)
Keep the configured app name casing when renaming
the updated executable so legacy MSI custom actions
can terminate custom client processes.

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-04 15:49:50 +08:00
Daniel Nylander
b6ff62c74b l10n: fill remaining Swedish entries (#16050)
Signed-off-by: Daniel Nylander <github@danielnylander.se>
2026-09-04 15:26:21 +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
Joss Gray
a59ad333fc fix: delimit FFmpeg pkg-config option (#16055) 2026-09-04 15:16:00 +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
Mariano Abad
3f93005be2 fix(drm): deliver a rotated output upright (#15886) (#15889)
* fix(drm): deliver a rotated output upright instead of sideways (#15886)

the compositor draws a rotated desktop sideways into the landscape
scanout and the physically turned monitor straightens it locally, so the
raw scanout the drm path ships reads sideways in the viewer, and nothing
rebroadcasts on rotation because the framebuffer size never changes.

the capturer now resolves the output transform once per session from the
wayland enumeration, turns accepted frames upright into its own buffer,
and sizes the session in rotated dimensions. the advertised list swaps
width and height for 90/270 outputs, which also makes a mid-session
rotation a topology change that restarts the service, and computes scale
from the post-swap width so a rotated 1:1 monitor no longer advertises
scale 16/9. a non 4-byte format on a rotated session is a hard error and
degrades through the existing health path.

the greeter path where no compositor answers keeps today's behavior:
there is no transform source there. hbb_common carries the new transform
field (submodule bump).

* fix(drm): drop the wayland snapshot when the live layout drifts (#15886)

the advertised list is augmented from the cached wayland snapshot and
nothing invalidated it mid-session, so a rotation the 1.5 s live poll
plainly saw never reached check_changed: the poll reads live, the
advertise kept serving the pre-rotation snapshot. measured before this
commit: transform applied and held, 'desktop layout changed' logged,
zero new encoders. cleared only when the poll saw an actual change, so
the probe cost stays tied to real layout events; after it, the same
stimulus rebuilds into a 1080x1920 encoder within a poll turn.

* refactor: trim comment density to the file norm

* chore: bump hbb_common to the transform field from rustdesk/hbb_common#586

pinned to the #586 commits atop the current pin rather than main tip:
main also carries an unrelated config-keys refactor the app has not
adopted yet, and both #586 commits are reachable upstream through the
merge.

* fix: advertise a lone rotated output at delivered size, one snapshot per session

review findings, both real: the 90/270 swap sat below the origin-only
cut, so a single rotated output advertised unrotated dimensions while
the capturer delivered rotated frames; and transform and origin came
from two get_displays() reads that could straddle a cache invalidation.
the swap now precedes the cut (logical-scale adoption stays multi
output), and new() resolves one snapshot for transform, origin and the
session size, with tests for both. comments trimmed to the three-line
guideline.

* fix(drm): rotate every space the rotation touches, not just the pixels

review findings on #15889, all verified against the code first.

the uinput rect's single-display branches now serve the delivered
orientation, so the pointer reaches the whole of a rotated screen (1).
DisplayRect carries the transform, making 0/180 and 90/270 flips
visible to the drift comparison (5), and the drift poll is an edge on
live-vs-previous rather than a level against the baseline, so the cache
clear fires once per real layout event instead of every 300 ms
forever (9). on the drm path the baseline promotes together with the
clear, so the remap and the client rebase never correct the same origin
delta twice (7), and the poll now runs above the login-screen return,
which was the one place with no other invalidation trigger (6).

a snapshot generation gives a rotation a rebuild path at last (3, 4):
clears bump it, the capturer records it at build, and a stale
generation asks for a rebuild without counting against display health.
the cursor bitmap and hotspot turn with the same session transform the
frames use (11). original_resolution follows the 90/270 swap (12). the
transform comes only from an identity match, never the layout-order
fallback (13), and a missing wayland snapshot at build logs the degrade
instead of silently pinning an unrotated session (10).

unrotate_bgra's body is now libyuv's ARGBRotate, which the existing
direction tests pin to the measured anchor (14). 180 stays master
behavior: i915 advertises hardware rotate-180 and wl_output cannot tell
hardware from software rotation, so undoing it blind would invert an
already-upright frame; it needs the plane rotation property on the
wire (2). the pipewire fallback guard's comment now states the rotated
reality it compares (8).

* fix(drm): one owner for the layout generation, one identity rule for rotation

adversarial pass over the previous commit, three structural findings.

the generation bump rode on the cache clear, which every video-service
start also executes, so any session init or restart tore down every
other live capturer, with no damping against a ping-pong between two
displays. the bump now has a single owner: the edge-detected layout
change in the display-service poll. cache clears are side-effect free
again, and a two-display session survives a third session's init with
zero spurious rebuilds.

the advertise side swapped dimensions for a layout-order-fallback match
while the capturer's transform refused such matches, splitting
advertised size from delivered frames into a black screen. both sides
now key off the same identity-match pass (identity_matches), so a
guessed assignment rotates nothing anywhere.

an edge observed while the drm verdict was transiently non-available
was consumed unpromoted, leaving a rotation sideways for the session;
it now stays owed until the verdict returns. an enumeration that failed
at build pinned transform 0 forever with a warn promising a retry that
did not exist; a missing snapshot now makes the first successful poll
an edge, so the degrade is bounded by the outage. the multi-display
missing-logical-size fallback serves delivered orientation, stale docs
zhou named are updated, and the resolutions list stays mode-space on
purpose: resolution changes ride xrandr, which is inert on this path.

* fix: transpose-tolerant fallback size check, log a rejected rotate geometry

whether a portal stream's caps arrive rotated on a 90/270 output is
unmeasured either way (pipewiresrc does not apply
SPA_META_VideoTransform), and this guard has already broken two readers
who reasoned from its comment - so the size half now accepts either
orientation instead of gambling a permanent offline on one. a source
stride shorter than a row logs the rejected geometry instead of
publishing a silent black frame. comments trimmed to the guideline and
the stale sole-test claim updated.

* fix(wayland): never serve a transposed PipeWire stream

The fallback accepted a stream whose dimensions were the advertised
display's transposed, but CapturerInfo keeps the stream dimensions,
nothing on the wayland side ever reconciles the client afterwards,
and the flutter renderer drops every frame whose size differs from
the advertised display - a permanently blank fallback. Accept only
the exact orientation; a transposed pair now falls into the existing
bail, the display is advertised offline, and the client recovers by
re-enumerating.

* fix(drm): keep the cursor consistent with the session transform

Two holes from the same review pass. The wire cursor id hashes only
the plane pixels and geometry, so a stream rebuilt under a new
transform resent the SAME id and the client's by-id cursor cache kept
the old orientation until the shape itself changed; fold the session
transform into the served id. And a cursor racing new()'s transform
store was processed with transform 0 and never corrected, since the
producer resends only on a shape change; hold that cursor and replay
it once the transform is in - the receive loop wakes at least every
200 ms, so the replay is prompt even on an idle wire.

* fix(wayland): the single-display carve-out must not forgive a transposed stream

The carve-out forgives a size difference (a Full Workspace stream may
report the workspace rather than the mode), but a transposed pair is
the same served-vs-advertised orientation split the previous commit
rejects, and it blanks the client the same way.

* fix(drm): a lone display with a rejected fallback is honestly offline

The transposed rejection promised 'advertised offline', but the
lone-display carve-out in mark_demoted_displays kept the display
online on the grounds that the whole-desktop fallback remains usable
- which is exactly what the rejection just refuted. The video service
then restart-looped against a stream nothing can serve, rebuilding
the portal session about once a second, while the client saw a
display list that lied.

Record the geometry rejection in the display health and let it end
the carve-out; a delivered frame or the demote-cooldown re-arm clears
it, so a recovered output comes back on its own.

* ci: retrigger, the previous run died in the actions outage (all root jobs at exactly 8m)

* fix(drm): a blind capturer owes a rebuild, and name matches reserve globally

Two of the review's findings. A capturer built during a failed wayland
enumeration recorded nothing durable: a later successful enumeration
refills the cache, wayland_snapshot_missing goes false, and the first
live poll sees no edge - the session stays sideways until an unrelated
change. The build now latches that it ran blind and the layout poll
consumes the latch into the existing owed-promotion machinery.

And the identity matcher ran per-connector, so a resolution guess for
an earlier connector could steal a later connector's exact name match
and pin its rotation on the wrong output. Names now reserve in a
global first pass; resolution pairing runs on the remainder only when
forced - one free output and one unmatched connector at that size.

* fix(drm): consume the blind-build latch even on a live-changed poll

Adversarial pass on the previous commit: the short-circuit left the
latch set on exactly the poll where live_changed fired (the common
blind-recovery ordering, since a failed enumeration is not cached and
failed_init makes the first successful poll an edge), and the stale
latch then bought a second, spurious promotion one poll later,
tearing down the freshly rebuilt capturer. The latch is now taken
unconditionally so both edge sources merge into one promotion.

* fix(wayland): hand over a layout change the poll has not seen yet

set_wayland_layout_baseline clears live, which is the edge detector's only
memory of the previous layout. ensure_inited calls it at the top of every video
service start, so a second monitor service starting between a rotation and the
next 1.5s poll recorded the rotated layout as the baseline: the poll then found
baseline == live_rects, owed no promotion, and the first capturer kept its old
transform. Under mutter's software rotation the framebuffer size does not
change and the wayland display-change check is disabled, so the stream stayed
sideways until the next layout event.

The setter now arms the promotion itself when the outgoing live differs from
the incoming baseline, which is the one choke point every caller goes through.
An empty incoming baseline is the DRM-union fallback and proves nothing.

* fix(wayland): the edge detector needs a memory a session init cannot erase

The baseline reset was also the edge detector's memory, so two session inits
straddling a rotation left nothing to compare the next poll against. Keep the
observed layout separate from the per-session input baseline; before the first
poll the outgoing baseline seeds it.

* fix(wayland): a capturer records the layout it was built on

ensure_inited() runs the wayland query before the capturer exists, and a failure
there saves an empty baseline. The capturer's own retry can succeed a moment
later and build on that layout, and because the build was not blind nothing
latched it, so a rotation before the first poll had no memory to be an edge
against and the stream stayed at the old transform.

The build now seeds the edge detector when nothing else has, and only then, so a
capturer built later cannot overwrite what the poll is keeping.

* fix(wayland): keep a capturer record that lost the race with the first poll

The constructor reads its wayland snapshot and records it in the edge
detector in two steps, and the layout poll can land between them. After a
failed session init (empty baseline) the constructor takes layout A and
publishes it, the output rotates, and the poll reads B live: nothing is
recorded yet and the snapshot is present, so it is no edge, and observe()
sets seen=B. The late note_capturer(A) then met a non-empty memory and was
dropped, so the capturer showed A while the detector held B, and B against
B never bumped the generation.

note_capturer now flags a build layout that disagrees with the poll's
memory instead of dropping it (overwriting is still wrong: on a
multi-display session that memory is what the other capturers were built
against). edge() reports the flag as an edge whatever the live layout is,
observe() consumes it right after, and a session init's baseline reset
leaves it alone. Regression test for the interleaving, with the promotion
consuming it, a baseline reset in between, and an agreeing late record as
the control.

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

* fix(wayland): a late capturer record from a promoted generation is not a second edge

The record can also land after the poll consumed an edge but before the
bump it promotes, or after the bump with a snapshot taken before it. That
capturer is stale by generation and rebuilds on its own, but the flag it
raised survived the promotion, and the next poll spent a second promotion
on the freshly rebuilt capturers.

Tag the record with the generation the capturer read before taking its
snapshot and count it as an edge only while that generation is current;
the newest generation wins when two records land. Regression test for the
consumed-edge interleaving, with a disagreeing record at the promoted
generation and a stale record after a fresh one as controls.

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

* chore: bump hbb_common to main tip

dc95b4f -> 05ed68f, a fast-forward: the flipped-transform warning and the
wlroots xdg-output positions (rustdesk/hbb_common#591, #592), 90-day logs,
the webrtc session cleanup deadlock fix and the hide-general-settings
option. No public API changes and no dependency changes.

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

---------

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:10:01 +08:00
Xinglin Qiang
23a147b0dc Filter detached DXGI outputs for Win+P single-display modes (#15814)
When Windows is set to "Show only on 1/2", DXGI still enumerates
detached outputs. Preferring that unfiltered list could select a
zero-size display as primary and hang clients waiting for video.
2026-09-03 16:04:09 +08:00
memory_clear
e4539fc304 Update cn.rs (#16041) 2026-09-03 10:08:30 +08:00
Maison da Silva
6dbd810454 Translate export-related strings to Portuguese (#16038)
Translate export-related strings to Portuguese
2026-09-03 08:51:05 +08:00
RustDesk
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
Stephan Paternotte
dfb5804dd0 Update nl.rs (#16036)
Re. export and import.
Without detailed information, reference or examples from en.rs, de.rs or fr.rs, I have simply translated the three strings verbatim
2026-09-02 21:49:37 +08:00
21pages
957dfe8c96 feat: add admin and control role API scripts (#16035)
- add admin role CRUD and membership management
  - add non-protobuf control role operations

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-02 21:47:03 +08:00
XLion
c312385ffd Update tw.rs (#16031)
* Update tw.rs

* Update tw.rs

* Update tw.rs
2026-09-02 14:36:49 +08:00
97 changed files with 4155 additions and 1130 deletions

View File

@@ -43,6 +43,7 @@ env:
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
VCPKG_CMAKE_VERSION: "4.3.0"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.5.0"
NDK_VERSION: "r28c"
@@ -389,6 +390,54 @@ jobs:
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
sha256sum ../../SignOutput/rustdesk-*.msi
- name: Build pre-built MSI template
# Two things this works around: preprocess.py rewrites res/msi in place, so the
# tree is reset around this second variant; and it locates the app as
# <app-name>.exe inside the dist, so the dist copy is renamed to match.
#
# The placeholder is chosen to keep this template as close to the shipped msi as
# possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX
# derives no short name for it. A longer placeholder would get one, and a patch
# cannot rewrite a truncated placeholder, leaving short names pointing at it.
#
# It still has to be unique, which is why "RustDesk" itself cannot be used:
# it also names payload that must never be renamed, such as librustdesk.dll
# and drivers\RustDeskPrinterDriver.
#
#
# Building the arm64 template on the native arm64 runner makes the ARM
# package available: the build agents are x64 and cannot run
# preprocess.py against an ARM exe.
if: env.UPLOAD_ARTIFACT == 'true'
run: |
git checkout -- res/msi
cp -r ./rustdesk ./rustdesk-msi-template
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline
$assets = './rustdesk-msi-template/data/flutter_assets/assets'
New-Item -ItemType Directory -Force -Path $assets | Out-Null
foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') {
Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline
}
pushd ./res/msi
python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
popd
mkdir ./msi-template
mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi
git checkout -- res/msi
rm -r -fo ./rustdesk-msi-template
- name: Upload unsigned msi template
if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }}
path: ./msi-template
- name: Sign rustdesk self-extracted file
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
shell: bash
@@ -925,15 +974,33 @@ jobs:
name: rustdesk-unsigned-windows-x86_64
path: ./windows-x86_64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-aarch64
path: ./windows-aarch64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-x86
path: ./windows-x86/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-x86_64
path: ./msi-template/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-aarch64
path: ./msi-template/
- name: Combine unsigned app
run: |
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
- name: Publish unsigned app
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
@@ -1470,7 +1537,6 @@ jobs:
submodules: recursive
- name: Set Swap Space
if: ${{ matrix.job.arch == 'x86_64' }}
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with:
swap-size-gb: 12
@@ -1505,6 +1571,15 @@ jobs:
name: bridge-artifact
path: ./
# vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31.
- name: Install CMake for vcpkg on Linux ARM64
if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true'
run: |
python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}"
user_base="$(python3 -m site --user-base)"
"${user_base}/bin/cmake" --version
echo "${user_base}/bin" >> "${GITHUB_PATH}"
- name: Setup vcpkg with Github Actions binary cache
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
@@ -2075,6 +2150,12 @@ jobs:
echo "Modified vcpkg.json for armv7 build:"
grep -A 2 -B 2 '"baseline"' vcpkg.json
- name: Set Swap Space
if: matrix.job.arch == 'armv7'
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with:
swap-size-gb: 12
- name: Free Space
run: |
df -h

View File

@@ -74,6 +74,25 @@
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
### Scope check before touching shared code
* Before changing a shared trait, a shared struct, or the signature of a widely used function, check whether the bug or feature is specific to one path. If it is, keep the change inside that path unless that is impossible, and say in the PR why it was.
* If an unrelated caller needs `Default::default()`, `None`, or another placeholder solely to satisfy a signature you changed, the diff is too broad: stop and redesign.
* The expected shape of a fix is a new function in the feature's own module, plus at most a new field or a thin hook in the shared code it needs. Feature-specific state belongs beside the feature's existing state, not in a new abstraction every caller has to learn.
### Mandatory regression-surface check
Before considering any implementation complete, perform a minimization pass over the final diff.
* Inspect every modified existing file and every modified existing code path. Each must be strictly necessary for the requested change. Revert changes that are merely cleanup, refactoring, consistency improvements, or fixes for pre-existing issues.
* For new features, preserve the existing implementation path when the feature is disabled or unsupported whenever practical. `feature off` should run the old code, not a rewritten equivalent.
* Do not route existing behavior through a new abstraction merely to share code with the new feature. Prefer a parallel new function or a small amount of duplication over changing a proven existing path.
* Keep new implementation logic in new or feature-specific modules. Changes to shared/core files should normally be thin hooks, capability checks, or protocol plumbing.
* Do not fix unrelated pre-existing bugs in the same PR. Put them in a separate change unless they directly block correctness or security of the requested work.
* For submodule bumps, inspect the exact commit range and ensure unrelated changes are not being pulled into the parent PR.
* Before finalizing, explicitly report the regression surface: list the existing files and existing runtime paths whose behavior changed, and explain why each change is unavoidable.
* During review, treat an unnecessarily modified legacy path as a review finding even if tests pass and the rewritten behavior appears equivalent.
## Reviewing a PR
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.

View File

@@ -244,11 +244,38 @@ List<(String, String)> otherDefaultSettings() {
kKeyUseAllMyDisplaysForTheRemoteSession
),
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
(
'Allow terminal apps to copy to clipboard',
kOptionAllowTerminalClipboardWrite
),
];
return v;
}
String getOtherDefaultSettingOption(String key) {
if (key == kOptionAllowTerminalClipboardWrite) {
return bind.mainGetLocalOption(key: key);
}
return bind.mainGetUserDefaultOption(key: key);
}
Future<void> setOtherDefaultSettingOption(String key, String value) {
if (key == kOptionAllowTerminalClipboardWrite) {
return bind.mainSetLocalOption(
key: key,
value: value == kTerminalClipboardWriteAllowed
? kTerminalClipboardWriteAllowed
: kTerminalClipboardWriteDenied,
);
}
return bind.mainSetUserDefaultOption(key: key, value: value);
}
bool isOtherDefaultSettingReadOnly(String key) =>
isOptionFixed(key) ||
(key == kOptionAllowTerminalClipboardWrite && bind.isDisableSettings());
class TrackpadSpeedWidget extends StatefulWidget {
final SimpleWrapper<int> value;
// If null, no debouncer will be applied.

View File

@@ -115,6 +115,11 @@ const String kOptionEnableAudio = "enable-audio";
const String kOptionEnableCamera = "enable-camera";
const String kOptionEnableTerminal = "enable-terminal";
const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionAllowTerminalClipboardWrite =
"allow-terminal-clipboard-write";
const String kTerminalClipboardWriteUnconfigured = "";
const String kTerminalClipboardWriteAllowed = "Y";
const String kTerminalClipboardWriteDenied = "N";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";

View File

@@ -330,12 +330,14 @@ class _ConnectionPageState extends State<ConnectionPage>
void onConnect(
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTerminal = false}) {
bool isTerminal = false,
bool isTcpTunneling = false}) {
var id = _idController.id;
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal);
isTerminal: isTerminal,
isTcpTunneling: isTcpTunneling);
}
/// UI for the remote ID TextField.
@@ -568,6 +570,14 @@ class _ConnectionPageState extends State<ConnectionPage>
'${translate('Terminal')} (beta)',
() => onConnect(isTerminal: true)
),
// `connect` routes this through the
// desktop path only; the peer card gates
// it the same way.
if (isDesktop)
(
'TCP tunneling',
() => onConnect(isTcpTunneling: true)
),
]
.map((e) => MenuEntryButton<String>(
childBuilder: (TextStyle? style) =>

View File

@@ -2080,14 +2080,13 @@ class _DisplayState extends State<_Display> {
}
Widget otherRow(String label, String key) {
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
final isOptFixed = isOptionFixed(key);
final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOtherDefaultSettingReadOnly(key);
onChanged(bool b) async {
await bind.mainSetUserDefaultOption(
key: key,
value: b
? 'Y'
: (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo));
await setOtherDefaultSettingOption(
key,
b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
);
setState(() {});
}

View File

@@ -19,6 +19,8 @@ class TerminalPage extends StatefulWidget {
required this.tabKey,
this.forceRelay,
this.connToken,
this.onClipboardWriteBlocked,
this.onClipboardWriteSucceeded,
}) : super(key: key);
final String id;
final String? password;
@@ -26,6 +28,8 @@ class TerminalPage extends StatefulWidget {
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final ValueChanged<String>? onClipboardWriteBlocked;
final ValueChanged<String>? onClipboardWriteSucceeded;
final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction
@@ -71,6 +75,8 @@ class _TerminalPageState extends State<TerminalPage>
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
_terminalModel.onClipboardWriteBlocked = widget.onClipboardWriteBlocked;
_terminalModel.onClipboardWriteSucceeded = widget.onClipboardWriteSucceeded;
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
@@ -10,6 +11,8 @@ import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:get/get.dart';
import '../../models/platform_model.dart';
@@ -19,6 +22,12 @@ import '../widgets/material_mod_popup_menu.dart' as mod_menu;
import '../widgets/popup_menu.dart';
import 'package:bot_toast/bot_toast.dart';
typedef _TerminalClipboardSource = ({
String peerId,
int terminalId,
String tabKey,
});
class TerminalTabPage extends StatefulWidget {
final Map<String, dynamic> params;
@@ -30,6 +39,18 @@ class TerminalTabPage extends StatefulWidget {
class _TerminalTabPageState extends State<TerminalTabPage> {
DesktopTabController get tabController => Get.find<DesktopTabController>();
bool get _canConfigureTerminalClipboardPermission =>
canConfigureTerminalClipboardPermission(
settingsDisabled: bind.isDisableSettings(),
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
);
bool get _canHandleTerminalClipboardWriteRequest =>
canHandleTerminalClipboardWriteRequest(
localOption: bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
),
canConfigurePermission: _canConfigureTerminalClipboardPermission,
);
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
@@ -38,6 +59,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final Set<String> _closingTabs = {};
// When true, all session cleanup should persist (window-level close in progress)
bool _windowClosing = false;
CancelFunc? _terminalClipboardNoticeCancel;
final _terminalClipboardNotice =
TerminalClipboardNoticeCoordinator<_TerminalClipboardSource>();
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -45,7 +69,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.onRemoved = (_, id) {
_closeTerminalClipboardNoticeForTab(id);
onRemoveId(id);
};
tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
@@ -70,6 +97,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
final tabLabel =
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
final clipboardSource = (
peerId: peerId,
terminalId: terminalId,
tabKey: tabKey,
);
return TabInfo(
key: tabKey,
label: tabLabel,
@@ -86,10 +118,169 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
tabController: tabController,
forceRelay: forceRelay,
connToken: connToken,
onClipboardWriteBlocked: _canHandleTerminalClipboardWriteRequest
? (text) => _handleTerminalClipboardWriteBlocked(
clipboardSource,
text,
)
: null,
onClipboardWriteSucceeded: (_) {
_handleTerminalClipboardWriteSucceeded(clipboardSource);
},
),
);
}
void _handleTerminalClipboardWriteBlocked(
_TerminalClipboardSource source,
String clipboardText,
) {
if (!mounted) return;
final option = bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
);
final request = _terminalClipboardNotice.recordBlocked(
source: source,
text: clipboardText,
option: option,
canWrite: _canWriteTerminalClipboard,
);
if (request != null) _showTerminalClipboardNotice(request);
}
void _showTerminalClipboardNotice(
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
) {
_terminalClipboardNoticeCancel = BotToast.showCustomNotification(
duration: null,
enableSlideOff: false,
onlyOne: true,
onClose: _handleTerminalClipboardNoticeClosed,
toastBuilder: (_) => AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => MaterialBanner(
leading: const Icon(Icons.content_copy_outlined),
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
actions: [
TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardNegativeAction
: null,
child: Text(translate(request.negativeActionKey)),
),
TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardPositiveAction
: null,
child: Text(translate(request.actionKey)),
),
],
),
),
);
}
void _handleTerminalClipboardNegativeAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
if (request.persistAllowed) {
unawaited(_declineTerminalClipboardWrite());
} else {
_closeTerminalClipboardNotice();
}
}
void _handleTerminalClipboardPositiveAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
unawaited(_completeTerminalClipboardWrite(request));
}
void _handleTerminalClipboardNoticeClosed() {
_terminalClipboardNoticeCancel = null;
_terminalClipboardNotice.noticeClosed();
}
bool _canWriteTerminalClipboard(
_TerminalClipboardSource source,
) {
if (!_canHandleTerminalClipboardWriteRequest) return false;
final ffi = TerminalConnectionManager.getExistingConnection(source.peerId);
return ffi != null &&
!ffi.closed &&
ffi.ffiModel.permissions['clipboard'] != false &&
tabController.state.value.tabs.any((tab) => tab.key == source.tabKey) &&
ffi.terminalModels.containsKey(source.terminalId);
}
void _handleTerminalClipboardWriteSucceeded(
_TerminalClipboardSource source,
) {
final request = _terminalClipboardNotice.currentForSource(source);
if (request == null) return;
_closeTerminalClipboardNotice();
}
Future<void> _declineTerminalClipboardWrite() async {
try {
await bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteDenied,
);
} catch (error) {
debugPrint(
'[TerminalTabPage] Failed to save terminal clipboard permission: $error');
return;
} finally {
_terminalClipboardNotice.releaseAction();
}
_closeTerminalClipboardNotice();
}
Future<void> _completeTerminalClipboardWrite(
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
) async {
final source = request.source;
var completed = false;
try {
completed = await completeTerminalClipboardWrite(
clipboardText: request.text,
canWrite: () => _canWriteTerminalClipboard(source),
writeClipboard: writeTerminalClipboard,
persistAllowed: request.persistAllowed
? () => bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteAllowed,
)
: null,
);
} catch (error) {
debugPrint(
'[TerminalTabPage] Failed to complete terminal clipboard write: $error');
} finally {
_terminalClipboardNotice.releaseAction();
}
if (!completed) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNoticeForTab(String tabKey) {
final current = _terminalClipboardNotice.current;
if (current?.source.tabKey != tabKey) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNotice() {
if (!_terminalClipboardNotice.beginClose()) return;
final cancel = _terminalClipboardNoticeCancel;
if (cancel == null) {
debugPrint('[TerminalTabPage] Clipboard notice controller is missing');
_terminalClipboardNotice.noticeClosed();
return;
}
cancel();
}
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
Future<void> _closeTab(String tabKey) async {
@@ -147,6 +338,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
// Keep the cleanup target lookup below synchronous before its first await:
// it relies on the current frame still retaining each TerminalPage's FFI/model.
_terminalClipboardNotice.clear();
_terminalClipboardNoticeCancel?.call();
tabController.clear();
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
@@ -357,6 +550,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
_terminalClipboardNotice.clear();
_terminalClipboardNoticeCancel?.call();
super.dispose();
}

View File

@@ -1269,16 +1269,18 @@ class __DisplayPageState extends State<_DisplayPage> {
}
SettingsTile otherRow(String label, String key) {
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
final isOptFixed = isOptionFixed(key);
final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOtherDefaultSettingReadOnly(key);
return SettingsTile.switchTile(
initialValue: value,
title: Text(translate(label)),
onToggle: isOptFixed
? null
: (b) async {
await bind.mainSetUserDefaultOption(
key: key, value: b ? 'Y' : defaultOptionNo);
await setOtherDefaultSettingOption(
key,
b ? 'Y' : defaultOptionNo,
);
setState(() {});
},
);

View File

@@ -11,6 +11,7 @@ import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:flutter_hbb/web/dummy.dart'
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
@@ -19,6 +20,49 @@ import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart';
const _terminalBackgroundOpacity = 0.7;
Widget _buildTerminalViewForPlatform({
required bool reportMouseInput,
required bool reportTouchInput,
required Terminal terminal,
required TerminalController controller,
required TerminalStyle textStyle,
required EdgeInsets padding,
required bool deleteDetection,
required Map<ShortcutActivator, Intent>? shortcuts,
required FocusOnKeyEventCallback onKeyEvent,
required void Function(TapDownDetails, CellOffset) onSecondaryTapDown,
}) {
if (reportMouseInput || reportTouchInput) {
return TerminalMouseInteraction(
terminal,
controller: controller,
autofocus: true,
textStyle: textStyle,
deleteDetection: deleteDetection,
reportTouchInput: reportTouchInput,
shortcuts: shortcuts,
onKeyEvent: onKeyEvent,
backgroundOpacity: _terminalBackgroundOpacity,
padding: padding,
onSecondaryTapDown: onSecondaryTapDown,
);
}
return TerminalView(
terminal,
controller: controller,
autofocus: true,
textStyle: textStyle,
deleteDetection: deleteDetection,
shortcuts: shortcuts,
onKeyEvent: onKeyEvent,
backgroundOpacity: _terminalBackgroundOpacity,
padding: padding,
onSecondaryTapDown: onSecondaryTapDown,
);
}
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
@@ -41,6 +85,19 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
bool get _canConfigureTerminalClipboardPermission =>
canConfigureTerminalClipboardPermission(
settingsDisabled: bind.isDisableSettings(),
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
);
bool get _canHandleTerminalClipboardWriteRequest =>
canHandleTerminalClipboardWriteRequest(
localOption: bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
),
canConfigurePermission: _canConfigureTerminalClipboardPermission,
);
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
@@ -57,6 +114,9 @@ class _TerminalPageState extends State<TerminalPage>
// For iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>?
_terminalClipboardNoticeController;
final _terminalClipboardNotice = TerminalClipboardNoticeCoordinator<int>();
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
@@ -89,6 +149,12 @@ class _TerminalPageState extends State<TerminalPage>
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
if (_canHandleTerminalClipboardWriteRequest) {
_terminalModel.onClipboardWriteBlocked =
_handleTerminalClipboardWriteBlocked;
_terminalModel.onClipboardWriteSucceeded =
_handleTerminalClipboardWriteSucceeded;
}
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
@@ -134,12 +200,144 @@ class _TerminalPageState extends State<TerminalPage>
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
void _handleTerminalClipboardWriteBlocked(String clipboardText) {
if (!mounted) return;
final option = bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
);
final request = _terminalClipboardNotice.recordBlocked(
source: widget.terminalId,
text: clipboardText,
option: option,
canWrite: (_) => _canWriteTerminalClipboard,
);
if (request != null) _showTerminalClipboardNotice(request);
}
void _showTerminalClipboardNotice(
TerminalClipboardNoticeRequest<int> request,
) {
final controller = ScaffoldMessenger.of(context).showMaterialBanner(
MaterialBanner(
leading: const Icon(Icons.content_copy_outlined),
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
actions: [
AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardNegativeAction
: null,
child: Text(translate(request.negativeActionKey)),
),
),
AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardPositiveAction
: null,
child: Text(translate(request.actionKey)),
),
),
],
),
);
_terminalClipboardNoticeController = controller;
unawaited(controller.closed.then<void>((_) {
if (identical(_terminalClipboardNoticeController, controller)) {
_terminalClipboardNoticeController = null;
_terminalClipboardNotice.noticeClosed();
}
}));
}
void _handleTerminalClipboardNegativeAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
if (request.persistAllowed) {
unawaited(_declineTerminalClipboardWrite());
} else {
_closeTerminalClipboardNotice();
}
}
void _handleTerminalClipboardPositiveAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
unawaited(_completeTerminalClipboardWrite(request));
}
bool get _canWriteTerminalClipboard =>
_canHandleTerminalClipboardWriteRequest &&
!_ffi.closed &&
_ffi.ffiModel.permissions['clipboard'] != false;
void _handleTerminalClipboardWriteSucceeded(String _) {
_closeTerminalClipboardNotice();
}
Future<void> _declineTerminalClipboardWrite() async {
try {
await bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteDenied,
);
} catch (error) {
debugPrint(
'[TerminalPage] Failed to save terminal clipboard permission: $error');
return;
} finally {
_terminalClipboardNotice.releaseAction();
}
_closeTerminalClipboardNotice();
}
Future<void> _completeTerminalClipboardWrite(
TerminalClipboardNoticeRequest<int> request,
) async {
var completed = false;
try {
completed = await completeTerminalClipboardWrite(
clipboardText: request.text,
canWrite: () => _canWriteTerminalClipboard,
writeClipboard: writeTerminalClipboard,
persistAllowed: request.persistAllowed
? () => bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteAllowed,
)
: null,
);
} catch (error) {
debugPrint(
'[TerminalPage] Failed to complete terminal clipboard write: $error');
} finally {
_terminalClipboardNotice.releaseAction();
}
if (!completed) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNotice() {
if (!_terminalClipboardNotice.beginClose()) return;
final controller = _terminalClipboardNoticeController;
if (controller == null) {
debugPrint('[TerminalPage] Clipboard notice controller is missing');
_terminalClipboardNotice.noticeClosed();
return;
}
controller.close();
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_keyboardDebounce?.cancel();
_terminalClipboardNotice.clear();
_terminalClipboardNoticeController?.close();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
@@ -234,12 +432,12 @@ class _TerminalPageState extends State<TerminalPage>
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
return _buildTerminalViewForPlatform(
reportMouseInput: isWebDesktop || isAndroid,
reportTouchInput: isIOS,
terminal: _terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
// The following comment is from xterm.dart source code:
// Workaround to detect delete key for platforms and IMEs that do not
// emit a hardware delete event. Preferred on mobile platforms. [false] by

View File

@@ -1,7 +1,108 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:xterm/xterm.dart';
enum TerminalClipboardWritePermission { denied, unconfigured, allowed }
class RustDeskTerminal extends Terminal {
RustDeskTerminal({super.maxLines});
RustDeskTerminal({
super.maxLines,
required TerminalClipboardWritePermission Function()
clipboardWritePermission,
required Future<bool> Function(String) onClipboardWrite,
ValueChanged<String>? onClipboardWriteBlocked,
ValueChanged<String>? onClipboardWriteSucceeded,
}) : _clipboardWritePermission = clipboardWritePermission,
_onClipboardWrite = onClipboardWrite,
_onClipboardWriteBlocked = onClipboardWriteBlocked,
_onClipboardWriteSucceeded = onClipboardWriteSucceeded {
onPrivateOSC = _handlePrivateOsc;
}
static const _clipboardOscCode = '52';
static const _systemClipboardSelection = 'c';
// Match the terminal helper's existing payload safety ceiling.
static const _maxClipboardWriteBytes = 16 * 1024 * 1024;
static const _base64InputBytesPerBlock = 3;
static const _base64EncodedCharsPerBlock = 4;
static final _osc52Selection = RegExp(r'^[cpqs0-7]*$');
final TerminalClipboardWritePermission Function() _clipboardWritePermission;
final Future<bool> Function(String) _onClipboardWrite;
final ValueChanged<String>? _onClipboardWriteBlocked;
final ValueChanged<String>? _onClipboardWriteSucceeded;
bool get isClipboardWriteAllowed =>
_clipboardWritePermission() == TerminalClipboardWritePermission.allowed;
void _handlePrivateOsc(String code, List<String> args) {
if (code != _clipboardOscCode) return;
if (args.length != 2 || !_osc52Selection.hasMatch(args.first)) {
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 command');
return;
}
if (args.last == '?') {
debugPrint('[RustDeskTerminal] Rejected OSC 52 clipboard query');
return;
}
final permission = _clipboardWritePermission();
if (permission == TerminalClipboardWritePermission.denied) {
debugPrint('[RustDeskTerminal] Rejected unauthorized OSC 52 write');
return;
}
final selection = args.first;
if (selection.isNotEmpty &&
!selection.contains(_systemClipboardSelection)) {
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selection');
return;
}
if (selection.replaceAll(_systemClipboardSelection, '').isNotEmpty) {
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selections');
}
final text = _decodeClipboardPayload(args.last);
if (text == null) return;
if (permission == TerminalClipboardWritePermission.unconfigured) {
debugPrint('[RustDeskTerminal] Blocked OSC 52 write pending consent');
_onClipboardWriteBlocked?.call(text);
return;
}
unawaited(_writeClipboard(text));
}
Future<void> _writeClipboard(String text) async {
final succeeded = await _onClipboardWrite(text);
if (succeeded) {
_onClipboardWriteSucceeded?.call(text);
return;
}
debugPrint(
'[RustDeskTerminal] OSC 52 clipboard write requires interaction');
_onClipboardWriteBlocked?.call(text);
}
String? _decodeClipboardPayload(String payload) {
if (payload.length > _maxBase64EncodedLength(_maxClipboardWriteBytes)) {
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
return null;
}
try {
final bytes = base64.decode(payload);
if (bytes.length > _maxClipboardWriteBytes) {
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
return null;
}
return utf8.decode(bytes);
} on FormatException {
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 payload');
return null;
}
}
static int _maxBase64EncodedLength(int maxBytes) =>
((maxBytes + _base64InputBytesPerBlock - 1) ~/
_base64InputBytesPerBlock) *
_base64EncodedCharsPerBlock;
@override
void eraseScrollbackOnly() {

View File

@@ -0,0 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
Future<bool> writeTerminalClipboardPlatform(
String text, {
bool userInitiated = false,
}) async {
try {
await Clipboard.setData(ClipboardData(text: text));
return true;
} catch (error) {
debugPrint('[Terminal] Failed to write clipboard: $error');
return false;
}
}

View File

@@ -0,0 +1,29 @@
import 'dart:js_interop';
import 'package:flutter/foundation.dart';
const _writeTerminalClipboardCommand = 'write_terminal_clipboard';
@JS('setByName')
external JSPromise<JSBoolean> _setByName(
JSString name,
JSString value,
JSBoolean userInitiated,
);
Future<bool> writeTerminalClipboardPlatform(
String text, {
bool userInitiated = false,
}) async {
try {
final result = await _setByName(
_writeTerminalClipboardCommand.toJS,
text.toJS,
userInitiated.toJS,
).toDart;
return result.toDart;
} catch (error) {
debugPrint('[Terminal] Failed to write Web clipboard: $error');
return false;
}
}

View File

@@ -3,20 +3,130 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:xterm/xterm.dart';
import 'terminal_clipboard_writer.dart'
if (dart.library.html) 'terminal_clipboard_writer_web.dart';
const _controlShiftVPasteShortcut = SingleActivator(
LogicalKeyboardKey.keyV,
control: true,
shift: true,
);
Future<void> writeTerminalClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
} catch (error) {
debugPrint('[Terminal] Failed to write clipboard: $error');
typedef TerminalClipboardWriter = Future<bool> Function(
String text, {
required bool userInitiated,
});
class TerminalClipboardNoticeRequest<T> {
const TerminalClipboardNoticeRequest({
required this.source,
required this.text,
required this.persistAllowed,
});
final T source;
final String text;
final bool persistAllowed;
String get actionKey => persistAllowed ? 'Enable' : 'Copy to clipboard';
String get negativeActionKey => persistAllowed ? 'Decline' : 'Dismiss';
}
const kTerminalClipboardNoticeMessageKey = 'terminal-clipboard-write-tip';
class TerminalClipboardNoticeCoordinator<T> extends ChangeNotifier {
TerminalClipboardNoticeRequest<T>? _current;
bool _noticeVisible = false;
bool _actionInProgress = false;
TerminalClipboardNoticeRequest<T>? get current => _current;
bool get canClaimAction =>
_noticeVisible && !_actionInProgress && _current != null;
TerminalClipboardNoticeRequest<T>? currentForSource(T source) {
final current = _current;
if (current == null || current.source != source) return null;
return current;
}
TerminalClipboardNoticeRequest<T>? recordBlocked({
required T source,
required String text,
required String option,
required bool Function(T source) canWrite,
}) {
if (!canWrite(source)) return null;
final requestAllowsPersistence =
option == kTerminalClipboardWriteUnconfigured;
if (option != kTerminalClipboardWriteAllowed && !requestAllowsPersistence) {
return null;
}
if (_noticeVisible && _actionInProgress) return null;
final wasVisible = _noticeVisible;
final persistAllowed =
wasVisible ? _current?.persistAllowed : requestAllowsPersistence;
final request = TerminalClipboardNoticeRequest(
source: source,
text: text,
persistAllowed: persistAllowed ?? requestAllowsPersistence,
);
_current = request;
if (wasVisible) return null;
_noticeVisible = true;
return request;
}
TerminalClipboardNoticeRequest<T>? claimCurrentAction() {
if (!canClaimAction) return null;
final current = _current;
if (current == null) return null;
_actionInProgress = true;
notifyListeners();
return current;
}
void releaseAction() {
if (!_actionInProgress) return;
_actionInProgress = false;
notifyListeners();
}
bool beginClose() {
if (!_noticeVisible) return false;
_actionInProgress = true;
notifyListeners();
return true;
}
void noticeClosed() => clear();
void clear() {
_current = null;
_noticeVisible = false;
_actionInProgress = false;
}
}
Future<bool> writeTerminalClipboard(
String text, {
bool userInitiated = false,
}) =>
writeTerminalClipboardPlatform(text, userInitiated: userInitiated);
Future<bool> completeTerminalClipboardWrite({
required String clipboardText,
required bool Function() canWrite,
required TerminalClipboardWriter writeClipboard,
Future<void> Function()? persistAllowed,
}) async {
if (!canWrite()) return false;
if (!await writeClipboard(clipboardText, userInitiated: true)) return false;
await persistAllowed?.call();
return true;
}
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
@@ -68,7 +178,7 @@ FocusOnKeyEventCallback terminalCopyHandler(
if (selection != null && !selection.isCollapsed) {
if (event is KeyDownEvent) {
final text = terminal.buffer.getText(selection);
unawaited(writeTerminalClipboard(text));
unawaited(writeTerminalClipboard(text, userInitiated: true));
}
return KeyEventResult.handled;
}

View File

@@ -11,8 +11,38 @@ import 'input_modifier_utils.dart';
import 'model.dart';
import 'platform_model.dart';
import 'rustdesk_terminal.dart';
import 'terminal_copy_shortcut.dart';
import 'terminal_mouse_handler.dart';
bool canConfigureTerminalClipboardPermission({
required bool settingsDisabled,
required bool optionFixed,
}) =>
!settingsDisabled && !optionFixed;
bool canHandleTerminalClipboardWriteRequest({
required String localOption,
required bool canConfigurePermission,
}) =>
canConfigurePermission || localOption == kTerminalClipboardWriteAllowed;
TerminalClipboardWritePermission terminalClipboardWritePermission(
String localOption, {
required bool remoteClipboardEnabled,
bool canRequestConsent = true,
}) {
if (!remoteClipboardEnabled) {
return TerminalClipboardWritePermission.denied;
}
if (localOption == kTerminalClipboardWriteAllowed) {
return TerminalClipboardWritePermission.allowed;
}
if (localOption == kTerminalClipboardWriteUnconfigured && canRequestConsent) {
return TerminalClipboardWritePermission.unconfigured;
}
return TerminalClipboardWritePermission.denied;
}
class TerminalModel with ChangeNotifier {
final String id; // peer id
final FFI parent;
@@ -62,6 +92,9 @@ class TerminalModel with ChangeNotifier {
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
VoidCallback? onClosed;
ValueChanged<String>? onClipboardWriteBlocked;
ValueChanged<String>? onClipboardWriteSucceeded;
Future<void> _handleInput(String data) async {
// xterm can complete asynchronous input after the Flutter page has gone
// away. Stop before reading or clearing widget-owned modifier state.
@@ -130,7 +163,19 @@ class TerminalModel with ChangeNotifier {
}
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
terminal = RustDeskTerminal(maxLines: 10000);
terminal = RustDeskTerminal(
maxLines: 10000,
onClipboardWrite: writeTerminalClipboard,
clipboardWritePermission: () => terminalClipboardWritePermission(
bind.mainGetLocalOption(key: kOptionAllowTerminalClipboardWrite),
remoteClipboardEnabled:
parent.ffiModel.permissions['clipboard'] != false,
canRequestConsent: onClipboardWriteBlocked != null,
),
onClipboardWriteBlocked: (text) => onClipboardWriteBlocked?.call(text),
onClipboardWriteSucceeded: (text) =>
onClipboardWriteSucceeded?.call(text),
);
terminal.mouseHandler = const WheelButtonFixMouseHandler();
terminalController = TerminalController();
@@ -593,6 +638,8 @@ class TerminalModel with ChangeNotifier {
clearAltLock = null;
onResizeExternal = null;
onClosed = null;
onClipboardWriteBlocked = null;
onClipboardWriteSucceeded = null;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();

View File

@@ -62,13 +62,17 @@ class TerminalMouseDragReporter {
var _ownsControllerSuspension = false;
var _releasePending = false;
var _reporting = false;
var _dragged = false;
bool handleDown(
PointerDownEvent event,
Terminal terminal,
TerminalViewState? terminalView,
) {
if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) {
TerminalViewState? terminalView, {
bool reportTouchInput = false,
bool deferReport = false,
}) {
if (!_isPrimaryPointer(event, reportTouchInput) ||
!_reportsDrag(terminal.mouseMode)) {
return false;
}
if (terminalView == null || terminalView.widget.readOnly) return false;
@@ -83,14 +87,33 @@ class TerminalMouseDragReporter {
_pointerId = event.pointer;
_controller = controller;
_ownsControllerSuspension = true;
_releasePending = true;
_reporting = true;
_releasePending = !deferReport;
_reporting = !deferReport;
_dragged = false;
controller.setSuspendPointerInput(true);
_clearSelection(controller);
final position = _cellAt(event, terminalView);
_lastReportedPosition = position;
if (!deferReport) {
terminal.textInput(
_report(terminal.mouseReportMode, position),
);
}
return true;
}
bool activateDeferredDown(Terminal terminal) {
if (_pointerId == null ||
_controller == null ||
_releasePending ||
!_reportsDrag(terminal.mouseMode)) {
return false;
}
_releasePending = true;
_reporting = true;
_clearSelection(_controller);
terminal.textInput(
_report(terminal.mouseReportMode, position),
_report(terminal.mouseReportMode, _lastReportedPosition),
);
return true;
}
@@ -98,26 +121,36 @@ class TerminalMouseDragReporter {
bool handleMove(
PointerMoveEvent event,
Terminal terminal,
TerminalViewState? terminalView,
) {
TerminalViewState? terminalView, {
void Function(bool dragged)? beforeRelease,
void Function()? onCancel,
}) {
if (event.pointer != _pointerId) return false;
if (terminalView == null) {
onCancel?.call();
cancel();
return true;
}
final reportsDrag = _reportsDrag(terminal.mouseMode);
if (!_isPrimaryMouse(event)) {
if (!_hasPrimaryButton(event)) {
if (_releasePending && reportsDrag) {
_reportRelease(
_finishRelease(
event,
terminal,
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
terminalView,
beforeRelease: beforeRelease,
);
} else {
onCancel?.call();
}
cancel();
return true;
}
if (!_reporting || !reportsDrag) {
if (!reportsDrag) _releasePending = false;
if (!reportsDrag && _releasePending) {
_releasePending = false;
onCancel?.call();
}
_reporting = false;
// Keep ownership until the matching end event to suppress local selection.
final controller = _controller;
@@ -126,7 +159,7 @@ class TerminalMouseDragReporter {
}
final position = _cellAt(event, terminalView);
_lastReportedPosition = position;
_recordPosition(position);
terminal.textInput(
_report(terminal.mouseReportMode, position, motion: true),
);
@@ -138,16 +171,22 @@ class TerminalMouseDragReporter {
bool handleEnd(
PointerEvent event,
Terminal terminal,
TerminalViewState? terminalView,
) {
TerminalViewState? terminalView, {
void Function(bool dragged)? beforeRelease,
void Function()? onCancel,
}) {
if (event.pointer != _pointerId) return false;
if (terminalView != null &&
_releasePending &&
_reportsDrag(terminal.mouseMode)) {
_reportRelease(
_finishRelease(
event,
terminal,
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
terminalView,
beforeRelease: beforeRelease,
);
} else {
onCancel?.call();
}
_clearSelection(_controller);
final controller = _controller;
@@ -172,6 +211,7 @@ class TerminalMouseDragReporter {
_ownsControllerSuspension = false;
_releasePending = false;
_reporting = false;
_dragged = false;
}
void updateController(TerminalController controller) {
@@ -203,6 +243,24 @@ class TerminalMouseDragReporter {
);
}
void _finishRelease(
PointerEvent event,
Terminal terminal,
TerminalViewState terminalView, {
void Function(bool dragged)? beforeRelease,
}) {
final position =
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition;
if (_reporting) _recordPosition(position);
beforeRelease?.call(_dragged);
_reportRelease(terminal, position);
}
void _recordPosition(CellOffset position) {
_dragged = _dragged || position != _lastReportedPosition;
_lastReportedPosition = position;
}
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
@@ -210,9 +268,13 @@ class TerminalMouseDragReporter {
);
}
bool _isPrimaryMouse(PointerEvent event) =>
event.kind == PointerDeviceKind.mouse &&
(event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton;
bool _isPrimaryPointer(PointerEvent event, bool reportTouchInput) =>
(event.kind == PointerDeviceKind.mouse ||
reportTouchInput && event.kind == PointerDeviceKind.touch) &&
_hasPrimaryButton(event);
bool _hasPrimaryButton(PointerEvent event) =>
(event.buttons & kPrimaryButton) == kPrimaryButton;
bool _reportsDrag(MouseMode mode) =>
mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove;

View File

@@ -1,45 +1,17 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:xterm/xterm.dart';
import 'platform_model.dart';
import 'rustdesk_terminal.dart';
import 'terminal_copy_shortcut.dart';
import 'terminal_mouse_drag_reporter.dart';
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
/// modifier, so strict full-screen apps ignore the report and never scroll.
/// Upstream fix: TerminalStudio/xterm.dart#238.
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
const WheelButtonFixMouseHandler({this.positionProvider});
final CellOffset? Function()? positionProvider;
@override
String? call(TerminalMouseEvent event) {
if (!event.button.isWheel) {
return defaultMouseHandler(event);
}
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
// and a wheel release is never reported, so the report is always a press.
if (!event.state.mouseMode.reportScroll ||
event.buttonState == TerminalMouseButtonState.up) {
return null;
}
return _reportWheel(event);
}
String _reportWheel(TerminalMouseEvent event) {
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
final button = event.button.id - 4;
final position = positionProvider?.call() ?? event.position;
return encodeTerminalMouseReport(
event.state.mouseReportMode,
button,
position,
);
}
}
part 'terminal_mouse_handler_input.dart';
part 'terminal_web_clipboard_gesture.dart';
class TerminalMouseInteraction extends StatefulWidget {
const TerminalMouseInteraction(
@@ -47,6 +19,12 @@ class TerminalMouseInteraction extends StatefulWidget {
super.key,
required this.controller,
this.focusNode,
this.autofocus = false,
this.textStyle = const TerminalStyle(),
this.deleteDetection = false,
this.reportTouchInput = false,
this.shortcuts,
this.onKeyEvent,
this.backgroundOpacity = 1,
this.padding,
this.onSecondaryTapDown,
@@ -55,6 +33,12 @@ class TerminalMouseInteraction extends StatefulWidget {
final Terminal terminal;
final TerminalController controller;
final FocusNode? focusNode;
final bool autofocus;
final TerminalStyle textStyle;
final bool deleteDetection;
final bool reportTouchInput;
final Map<ShortcutActivator, Intent>? shortcuts;
final FocusOnKeyEventCallback? onKeyEvent;
final double backgroundOpacity;
final EdgeInsets? padding;
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
@@ -81,8 +65,13 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
Buffer? _selectionBuffer;
int? _selectionPointerId;
Timer? _selectionScrollTimer;
Timer? _pendingTouchMouseTimer;
PointerDownEvent? _pendingTouchMouseDown;
var _selectionHasScrolled = false;
var _scrollDirection = _noScroll;
// xterm can finish its tap callbacks after the raw drag was reported.
var _suppressXtermLeftButton = false;
var _terminalClipboardGesturePrepared = false;
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
@override
@@ -90,6 +79,7 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
super.initState();
_mouseHandler = WheelButtonFixMouseHandler(
positionProvider: _cellAtPointer,
suppressLeftButton: kIsWeb ? _consumeXtermLeftButtonSuppression : null,
);
_installMouseHandler(widget.terminal);
}
@@ -100,10 +90,15 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
final controllerChanged =
!identical(oldWidget.controller, widget.controller);
final touchInputChanged =
oldWidget.reportTouchInput != widget.reportTouchInput;
if (!terminalChanged && !controllerChanged && !touchInputChanged) return;
_cancelPendingTouchMouseDrag();
if (!terminalChanged && !controllerChanged) return;
if (controllerChanged && !terminalChanged) {
_mouseDrag.updateController(widget.controller);
} else {
_discardPendingTerminalClipboardWrites();
_mouseDrag.cancel();
}
_clearSelectionDrag();
@@ -123,46 +118,18 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
}
}
CellOffset? _cellAtPointer() {
final terminalView = _terminalView;
final pointerPosition = _pointerPosition;
if (terminalView == null || pointerPosition == null) return null;
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
renderTerminal.globalToLocal(pointerPosition),
);
}
void _updatePointerPosition(PointerEvent event) =>
_pointerPosition = event.position;
void _handlePointerDown(PointerDownEvent event) {
_updatePointerPosition(event);
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
_clearSelectionDrag();
return;
}
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
return;
}
_clearSelectionDrag();
final terminalView = _terminalView;
if (terminalView == null) return;
final renderTerminal = terminalView.renderTerminal;
final localPosition = renderTerminal.globalToLocal(event.position);
final selectionBuffer = widget.terminal.buffer;
_selectionPointerId = event.pointer;
_selectionBase = selectionBuffer.createAnchorFromOffset(
renderTerminal.getCellOffset(localPosition),
);
_selectionBuffer = selectionBuffer;
_selectionPointer = localPosition;
}
void _handlePointerMove(PointerMoveEvent event) {
_updatePointerPosition(event);
if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return;
if (_handlePendingTouchMove(event)) return;
if (_mouseDrag.handleMove(
event,
widget.terminal,
_terminalView,
beforeRelease: _finishTerminalClipboardWrite,
onCancel: _cancelTerminalClipboardWrite,
)) {
return;
}
if (event.pointer != _selectionPointerId) return;
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
@@ -241,8 +208,28 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
void _handlePointerEnd(PointerEvent event) {
_updatePointerPosition(event);
if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) &&
event.pointer != _selectionPointerId) return;
final pendingTouch = _pendingTouchMouseDown;
if (pendingTouch != null && pendingTouch.pointer == event.pointer) {
final movedBeyondSlop =
(event.position - pendingTouch.position).distance > kTouchSlop;
if (event is PointerUpEvent && !movedBeyondSlop) {
_activatePendingTouchMouseDrag(cancelOnFailure: false);
} else {
_takePendingTouchMouseDrag(pointer: event.pointer);
}
}
final handledByMouseDrag = _mouseDrag.handleEnd(
event,
widget.terminal,
_terminalView,
beforeRelease: event is PointerUpEvent
? _finishTerminalClipboardWrite
: (_) => _cancelTerminalClipboardWrite(),
onCancel: _cancelTerminalClipboardWrite,
);
if (!handledByMouseDrag && event.pointer != _selectionPointerId) {
return;
}
if (_selectionHasScrolled) _scrollSelection(scroll: false);
_clearSelectionDrag();
}
@@ -265,6 +252,8 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
@override
void dispose() {
_discardPendingTerminalClipboardWrites();
_cancelPendingTouchMouseDrag();
_mouseDrag.cancel();
_clearSelectionDrag();
_restoreMouseHandler(widget.terminal);
@@ -290,10 +279,14 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
controller: widget.controller,
scrollController: _scrollController,
focusNode: widget.focusNode,
autofocus: widget.autofocus,
textStyle: widget.textStyle,
deleteDetection: widget.deleteDetection,
backgroundOpacity: widget.backgroundOpacity,
padding: widget.padding,
shortcuts: platformTerminalShortcuts(),
onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller),
shortcuts: widget.shortcuts ?? platformTerminalShortcuts(),
onKeyEvent: widget.onKeyEvent ??
terminalCopyHandler(widget.terminal, widget.controller),
onSecondaryTapDown: widget.onSecondaryTapDown,
),
);

View File

@@ -0,0 +1,162 @@
part of 'terminal_mouse_handler.dart';
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
/// modifier, so strict full-screen apps ignore the report and never scroll.
/// Upstream fix: TerminalStudio/xterm.dart#238.
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
const WheelButtonFixMouseHandler({
this.positionProvider,
this.suppressLeftButton,
});
final CellOffset? Function()? positionProvider;
final bool Function(TerminalMouseButtonState)? suppressLeftButton;
@override
String? call(TerminalMouseEvent event) {
if (!event.button.isWheel) {
if (event.button == TerminalMouseButton.left &&
suppressLeftButton?.call(event.buttonState) == true) {
return null;
}
return defaultMouseHandler(event);
}
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
// and a wheel release is never reported, so the report is always a press.
if (!event.state.mouseMode.reportScroll ||
event.buttonState == TerminalMouseButtonState.up) {
return null;
}
return _reportWheel(event);
}
String _reportWheel(TerminalMouseEvent event) {
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
final button = event.button.id - 4;
final position = positionProvider?.call() ?? event.position;
return encodeTerminalMouseReport(
event.state.mouseReportMode,
button,
position,
);
}
}
extension _TerminalMouseInput on _TerminalMouseInteractionState {
CellOffset? _cellAtPointer() {
final terminalView = _terminalView;
final pointerPosition = _pointerPosition;
if (terminalView == null || pointerPosition == null) return null;
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
renderTerminal.globalToLocal(pointerPosition),
);
}
void _updatePointerPosition(PointerEvent event) =>
_pointerPosition = event.position;
void _handlePointerDown(PointerDownEvent event) {
_updatePointerPosition(event);
_suppressXtermLeftButton = false;
if (_startPendingTouchMouseDrag(event)) return;
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
_prepareTerminalClipboardWrite();
if (kIsWeb) _suppressXtermLeftButton = true;
_clearSelectionDrag();
return;
}
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
return;
}
_clearSelectionDrag();
final terminalView = _terminalView;
if (terminalView == null) return;
final renderTerminal = terminalView.renderTerminal;
final localPosition = renderTerminal.globalToLocal(event.position);
final selectionBuffer = widget.terminal.buffer;
_selectionPointerId = event.pointer;
_selectionBase = selectionBuffer.createAnchorFromOffset(
renderTerminal.getCellOffset(localPosition),
);
_selectionBuffer = selectionBuffer;
_selectionPointer = localPosition;
}
bool _startPendingTouchMouseDrag(PointerDownEvent event) {
if (!widget.reportTouchInput ||
event.kind != PointerDeviceKind.touch ||
!_mouseDrag.handleDown(
event,
widget.terminal,
_terminalView,
reportTouchInput: true,
deferReport: true,
)) {
return false;
}
_pendingTouchMouseDown = event;
_pendingTouchMouseTimer = Timer(
kLongPressTimeout,
_activatePendingTouchMouseDrag,
);
return true;
}
bool _activatePendingTouchMouseDrag({
bool cancelOnFailure = true,
}) {
if (_takePendingTouchMouseDrag() == null) return false;
if (_mouseDrag.activateDeferredDown(widget.terminal)) {
_prepareTerminalClipboardWrite();
_clearSelectionDrag();
return true;
}
if (cancelOnFailure) _mouseDrag.cancel();
return false;
}
PointerDownEvent? _takePendingTouchMouseDrag({int? pointer}) {
final pending = _pendingTouchMouseDown;
if (pending == null || pointer != null && pointer != pending.pointer) {
return null;
}
_pendingTouchMouseTimer?.cancel();
_pendingTouchMouseTimer = null;
_pendingTouchMouseDown = null;
return pending;
}
void _cancelPendingTouchMouseDrag({
int? pointer,
bool deferCancel = false,
}) {
if (_takePendingTouchMouseDrag(pointer: pointer) == null) return;
if (deferCancel) {
scheduleMicrotask(_mouseDrag.cancel);
} else {
_mouseDrag.cancel();
}
}
bool _handlePendingTouchMove(PointerMoveEvent event) {
final pending = _pendingTouchMouseDown;
if (pending == null || pending.pointer != event.pointer) return false;
if ((event.position - pending.position).distance > kTouchSlop) {
_cancelPendingTouchMouseDrag(
pointer: event.pointer,
deferCancel: true,
);
}
return true;
}
bool _consumeXtermLeftButtonSuppression(TerminalMouseButtonState state) {
final suppress = _suppressXtermLeftButton;
if (state == TerminalMouseButtonState.up) {
_suppressXtermLeftButton = false;
}
return suppress;
}
}

View File

@@ -0,0 +1,56 @@
part of 'terminal_mouse_handler.dart';
const _prepareTerminalClipboardCommand = 'prepare_terminal_clipboard';
const _finishTerminalClipboardCommand = 'finish_terminal_clipboard';
const _cancelTerminalClipboardCommand = 'cancel_terminal_clipboard';
extension _TerminalWebClipboardGesture on _TerminalMouseInteractionState {
void _prepareTerminalClipboardWrite() {
if (!kIsWeb) return;
_cancelTerminalClipboardWrite();
final terminal = widget.terminal;
if (terminal is! RustDeskTerminal || !terminal.isClipboardWriteAllowed) {
return;
}
try {
ffiSetByName(_prepareTerminalClipboardCommand);
_terminalClipboardGesturePrepared = true;
} catch (error) {
debugPrint('[Terminal] Failed to prepare Web clipboard write: $error');
}
}
void _finishTerminalClipboardWrite(bool responseExpected) {
if (!_terminalClipboardGesturePrepared) return;
_terminalClipboardGesturePrepared = false;
if (!kIsWeb) return;
try {
ffiSetByName(
_finishTerminalClipboardCommand,
responseExpected ? 'true' : 'false',
);
} catch (error) {
debugPrint('[Terminal] Failed to finish Web clipboard write: $error');
}
}
void _cancelTerminalClipboardWrite() {
if (!_terminalClipboardGesturePrepared) return;
_terminalClipboardGesturePrepared = false;
_sendTerminalClipboardCancel();
}
void _discardPendingTerminalClipboardWrites() {
_cancelTerminalClipboardWrite();
_sendTerminalClipboardCancel();
}
void _sendTerminalClipboardCancel() {
if (!kIsWeb) return;
try {
ffiSetByName(_cancelTerminalClipboardCommand);
} catch (error) {
debugPrint('[Terminal] Failed to cancel Web clipboard write: $error');
}
}
}

View File

@@ -12,7 +12,7 @@ build = "build.rs"
brotli = "3.4"
dirs = "5.0"
md5 = "0.7"
winapi = { version = "0.3", features = ["winbase"] }
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = [

View File

@@ -15,15 +15,29 @@ encoding = 'utf-8'
# output: {path: (compressed_data, file_md5)}
def generate_md5_table(folder: str, level) -> dict:
def normalize(path: str) -> str:
path = path.replace('\\', '/')
while path.startswith('./'):
path = path[2:]
return path.lower()
def generate_md5_table(folder: str, level, exclude: str = None) -> dict:
res: dict = dict()
curdir = os.curdir
skip = normalize(exclude) if exclude else None
excluded = False
# os.curdir is the literal ".", so restoring it left us inside `folder`.
curdir = os.getcwd()
os.chdir(folder)
for root, _, files in os.walk('.'):
# remove ./
for f in files:
md5_generator = md5()
full_path = os.path.join(root, f)
if skip and normalize(full_path) == skip:
print(f"Excluding {full_path}...")
excluded = True
continue
print(f"Processing {full_path}...")
f = open(full_path, "rb")
content = f.read()
@@ -33,11 +47,16 @@ def generate_md5_table(folder: str, level) -> dict:
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
res[full_path] = (content_compressed, md5_code)
os.chdir(curdir)
if skip and not excluded:
raise ValueError(f"excluded file was not found in {folder}: {exclude}")
return res
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
output_path = os.path.join(output_folder, "data.bin")
write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe)
def write_blob(md5_table: dict, output_path: str, exe: str):
with open(output_path, "wb") as f:
f.write("rustdesk".encode(encoding=encoding))
for path in md5_table.keys():
@@ -92,6 +111,14 @@ if __name__ == '__main__':
help="the target used by cargo")
parser.add_option("-l", "--level", dest="level", type="int",
help="compression level, default is 11, highest", default=11)
parser.add_option("--package", dest="package",
help="write the per-customer blob to this path instead of "
"data.bin, and skip the cargo build. Injected into the "
"template's RDPKG resource so customizing needs no rebuild")
parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true",
default=False,
help="omit the executable from the blob, for a template whose "
"executable ships in the package instead")
(options, args) = parser.parse_args()
folder = options.folder or './rustdesk'
output_folder = os.path.abspath(options.output_folder or './')
@@ -100,14 +127,29 @@ if __name__ == '__main__':
options.executable = 'rustdesk.exe'
if not options.executable.startswith(folder):
options.executable = folder + '/' + options.executable
# Note: the simple check `options.executable.startswith(folder)` is incorrect.
# `python generate.py -f rustdesk -e rustdesk.exe` or `python generate.py -f rustdesk`
# will result the print "Executable path: ..exe".
# So we need to check if the executable is in the folder, and if so, concat again.
if os.path.exists(os.path.join(folder, options.executable)):
options.executable = os.path.join(folder, options.executable)
folder_path = os.path.abspath(folder)
exe: str = os.path.abspath(options.executable)
if not exe.startswith(os.path.abspath(folder)):
try:
in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path
except ValueError:
in_source_folder = False
if not in_source_folder:
print("The executable must locate in source folder")
exit(-1)
exe = '.' + exe[len(os.path.abspath(folder)):]
exe = '.' + exe[len(folder_path):]
print("Executable path: " + exe)
print("Compression level: " + str(options.level))
md5_table = generate_md5_table(folder, options.level)
write_package_metadata(md5_table, output_folder, exe)
write_app_metadata(output_folder)
build_portable(output_folder, options.target)
md5_table = generate_md5_table(
folder, options.level, exe if options.exclude_exe else None)
if options.package:
write_blob(md5_table, os.path.abspath(options.package), exe)
else:
write_package_metadata(md5_table, output_folder, exe)
write_app_metadata(output_folder)
build_portable(output_folder, options.target)

View File

@@ -1,15 +1,22 @@
use std::{
collections::HashSet,
fs::{self},
io::{Cursor, Read},
path::Path,
};
// The generic payload, shared by every customer and compiled in once per release.
#[cfg(windows)]
const BIN_DATA: &[u8] = include_bytes!("../data.bin");
#[cfg(not(windows))]
const BIN_DATA: &[u8] = &[];
// The per-customer payload, injected into the RCDATA resource after the template
// has been built, so that customizing a client needs no recompilation.
#[cfg(windows)]
const PACKAGE_RESOURCE_NAME: &str = "RDPKG";
// 4bytes
const LENGTH: usize = 4;
const IDENTIFIER: &[u8] = b"rustdesk";
const IDENTIFIER_LENGTH: usize = 8;
const MD5_LENGTH: usize = 32;
const BUF_SIZE: usize = 4096;
@@ -24,12 +31,172 @@ pub(crate) struct BinaryData {
pub(crate) struct BinaryReader {
pub files: Vec<BinaryData>,
pub exe: String,
// Paths supplied by the per-customer package. Recorded so that a file dropped
// from a later package -- a logo the customer removed, say -- can be deleted
// from an existing extraction, which the timestamp wipe no longer covers now
// that the packer is built once per release rather than once per customer.
pub package_paths: Vec<String>,
}
impl Default for BinaryReader {
fn default() -> Self {
let (files, exe) = BinaryReader::read();
Self { files, exe }
impl BinaryReader {
pub fn new() -> Result<Self, String> {
let package = read_package()?;
let package_paths = package.0.iter().map(|f| f.path.clone()).collect();
let (files, exe) = merge(read_embedded()?, package);
Ok(Self {
files,
exe,
package_paths,
})
}
}
// Folds the per-customer package into the generic payload.
fn merge(
embedded: (Vec<BinaryData>, String),
package: (Vec<BinaryData>, String),
) -> (Vec<BinaryData>, String) {
let (mut files, generic_exe) = embedded;
let (package_files, package_exe) = package;
let exe = if package_exe.is_empty() {
generic_exe.clone()
} else {
package_exe
};
// The generic payload ships the executable under its stock name, the package
// decides the final one. Rename on extraction so the process is always
// `<appname>.exe`, which the app itself relies on to find its own sessions.
if !generic_exe.is_empty() && normalize_path(&exe) != normalize_path(&generic_exe) {
let generic_key = normalize_path(&generic_exe);
for file in files.iter_mut() {
if normalize_path(&file.path) == generic_key {
file.path = exe.clone();
}
}
}
// Per-customer entries replace the generic ones they shadow.
if !package_files.is_empty() {
let overridden: HashSet<String> = package_files
.iter()
.map(|file| normalize_path(&file.path))
.collect();
files.retain(|file| !overridden.contains(&normalize_path(&file.path)));
files.extend(package_files);
}
(files, exe)
}
pub(crate) fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.to_lowercase()
}
fn read_u32(blob: &[u8], at: usize) -> Option<u32> {
let bytes = blob.get(at..at + LENGTH)?;
Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
// Returns the files and the executable to launch, or None if the blob is absent or malformed.
fn parse(blob: &'static [u8]) -> Option<(Vec<BinaryData>, String)> {
let mut base = 0usize;
let mut parsed = Vec::new();
if blob.get(base..base + IDENTIFIER_LENGTH)? != IDENTIFIER {
return None;
}
base += IDENTIFIER_LENGTH;
loop {
if blob.get(base..base + IDENTIFIER_LENGTH)? == IDENTIFIER {
base += IDENTIFIER_LENGTH;
break;
}
let path_length = read_u32(blob, base)? as usize;
base += LENGTH;
let path = std::str::from_utf8(blob.get(base..base + path_length)?)
.ok()?
.to_owned();
base += path_length;
let file_length = read_u32(blob, base)? as usize;
base += LENGTH;
let raw = blob.get(base..base + file_length)?;
base += file_length;
let md5_code = blob.get(base..base + MD5_LENGTH)?;
base += MD5_LENGTH;
parsed.push(BinaryData {
md5_code,
raw,
path,
});
}
let executable = std::str::from_utf8(blob.get(base..)?).ok()?.to_owned();
Some((parsed, executable))
}
#[cfg(windows)]
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
parse(BIN_DATA).ok_or_else(|| "bin file is not valid!".to_owned())
}
#[cfg(not(windows))]
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
Ok(Default::default())
}
fn parse_package_blob(blob: Option<&'static [u8]>) -> Result<(Vec<BinaryData>, String), String> {
let Some(blob) = blob else {
return Ok(Default::default());
};
let package = parse(blob).ok_or_else(|| "RDPKG resource is invalid".to_owned())?;
if package.1.trim().is_empty() {
return Err("RDPKG resource has no executable".to_owned());
}
Ok(package)
}
#[cfg(windows)]
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
parse_package_blob(read_resource(PACKAGE_RESOURCE_NAME))
}
#[cfg(not(windows))]
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
Ok(Default::default())
}
// Reads an RCDATA resource out of the running image. Resources live in the mapped
// image for the lifetime of the process, so the slice is genuinely 'static and no
// copy is needed.
#[cfg(windows)]
fn read_resource(name: &str) -> Option<&'static [u8]> {
use std::ptr::null_mut;
use winapi::um::libloaderapi::{FindResourceW, LoadResource, LockResource, SizeofResource};
// MAKEINTRESOURCEW(10), avoids depending on the winuser feature for RT_RCDATA.
const RT_RCDATA: *const u16 = 10 as _;
let name: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
let info = FindResourceW(null_mut(), name.as_ptr(), RT_RCDATA);
if info.is_null() {
return None;
}
let size = SizeofResource(null_mut(), info) as usize;
if size == 0 {
return None;
}
let handle = LoadResource(null_mut(), info);
if handle.is_null() {
return None;
}
let data = LockResource(handle) as *const u8;
if data.is_null() {
return None;
}
Some(std::slice::from_raw_parts(data, size))
}
}
@@ -68,59 +235,6 @@ impl BinaryData {
}
impl BinaryReader {
fn read() -> (Vec<BinaryData>, String) {
let mut base: usize = 0;
let mut parsed = vec![];
assert!(BIN_DATA.len() > IDENTIFIER_LENGTH, "bin data invalid!");
let mut iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
if iden != "rustdesk" {
panic!("bin file is not valid!");
}
base += IDENTIFIER_LENGTH;
loop {
iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
if iden == "rustdesk" {
base += IDENTIFIER_LENGTH;
break;
}
// start reading
let mut offset = 0;
let path_length = u32::from_be_bytes([
BIN_DATA[base + offset],
BIN_DATA[base + offset + 1],
BIN_DATA[base + offset + 2],
BIN_DATA[base + offset + 3],
]) as usize;
offset += LENGTH;
let path =
String::from_utf8_lossy(&BIN_DATA[base + offset..base + offset + path_length])
.to_string();
offset += path_length;
// file sz
let file_length = u32::from_be_bytes([
BIN_DATA[base + offset],
BIN_DATA[base + offset + 1],
BIN_DATA[base + offset + 2],
BIN_DATA[base + offset + 3],
]) as usize;
offset += LENGTH;
let raw = &BIN_DATA[base + offset..base + offset + file_length];
offset += file_length;
// md5
let md5 = &BIN_DATA[base + offset..base + offset + MD5_LENGTH];
offset += MD5_LENGTH;
parsed.push(BinaryData {
md5_code: md5,
raw: raw,
path: path,
});
base += offset;
}
// executable
let executable = String::from_utf8_lossy(&BIN_DATA[base..]).to_string();
(parsed, executable)
}
#[cfg(linux)]
pub fn configure_permission(&self, prefix: &Path) {
use std::os::unix::prelude::PermissionsExt;
@@ -137,3 +251,155 @@ impl BinaryReader {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Builds a blob in the same layout generate.py writes, so these tests pin the
// cross-language format contract as well as the merge rules.
fn blob(files: &[(&str, &[u8])], exe: &str) -> &'static [u8] {
let mut out = Vec::new();
out.extend_from_slice(IDENTIFIER);
for (path, data) in files {
out.extend_from_slice(&(path.len() as u32).to_be_bytes());
out.extend_from_slice(path.as_bytes());
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(data);
out.extend_from_slice(&[b'a'; MD5_LENGTH]);
}
out.extend_from_slice(IDENTIFIER);
out.extend_from_slice(exe.as_bytes());
Box::leak(out.into_boxed_slice())
}
fn entry<'a>(files: &'a [BinaryData], path: &str) -> Option<&'a BinaryData> {
files
.iter()
.find(|file| normalize_path(&file.path) == normalize_path(path))
}
#[test]
fn parses_the_generate_py_layout() {
let (files, exe) = parse(blob(
&[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")],
"./rustdesk.exe",
))
.unwrap();
assert_eq!(exe, "./rustdesk.exe");
assert_eq!(files.len(), 2);
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
}
#[test]
fn rejects_malformed_blobs() {
assert!(parse(b"".as_slice()).is_none());
assert!(parse(b"notrustd".as_slice()).is_none());
// Truncated mid-record rather than panicking on a slice out of range.
assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none());
}
#[test]
fn distinguishes_an_absent_package_from_a_malformed_one() {
assert!(parse_package_blob(None).unwrap().0.is_empty());
assert!(parse_package_blob(Some(b"damaged")).is_err());
assert!(parse_package_blob(Some(blob(&[("./custom.txt", b"cfg")], ""))).is_err());
}
#[test]
fn without_a_package_the_stock_payload_is_untouched() {
let embedded = parse(blob(&[("./rustdesk.exe", b"app")], "./rustdesk.exe")).unwrap();
let (files, exe) = merge(embedded, Default::default());
assert_eq!(exe, "./rustdesk.exe");
assert!(entry(&files, "./rustdesk.exe").is_some());
}
#[test]
fn renames_the_stock_executable_to_the_package_name() {
// x86: the big executable stays in the generic payload and only gets renamed.
let embedded = parse(blob(
&[("./rustdesk.exe", b"app"), ("./sciter.dll", b"dll")],
"./rustdesk.exe",
))
.unwrap();
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
let (files, exe) = merge(embedded, package);
assert_eq!(exe, "./acme.exe");
assert!(entry(&files, "./acme.exe").is_some());
assert!(entry(&files, "./rustdesk.exe").is_none());
// Untouched neighbours survive.
assert_eq!(entry(&files, "./sciter.dll").unwrap().raw, b"dll");
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
}
#[test]
fn package_entries_win_over_the_generic_payload() {
// x64: the customized executable and icons ship in the package instead.
let embedded = parse(blob(
&[
("./data/flutter_assets/assets/icon.ico", b"stock-icon"),
("./librustdesk.dll", b"core"),
],
"./rustdesk.exe",
))
.unwrap();
let package = parse(blob(
&[
("./acme.exe", b"branded"),
("./data/flutter_assets/assets/icon.ico", b"acme-icon"),
],
"./acme.exe",
))
.unwrap();
let (files, exe) = merge(embedded, package);
assert_eq!(exe, "./acme.exe");
assert_eq!(
entry(&files, "./data/flutter_assets/assets/icon.ico")
.unwrap()
.raw,
b"acme-icon"
);
assert_eq!(
files
.iter()
.filter(|f| normalize_path(&f.path) == "data/flutter_assets/assets/icon.ico")
.count(),
1
);
assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core");
}
#[test]
fn package_paths_are_recorded_for_the_dropped_file_sweep() {
let package = parse(blob(
&[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")],
"./acme.exe",
))
.unwrap();
let mut paths: Vec<String> = package.0.iter().map(|f| f.path.clone()).collect();
paths.sort();
assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]);
// Merging must not disturb them: the generic payload contributes none.
let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap();
let (files, _) = merge(embedded, package);
assert!(entry(&files, "./data/logo.png").is_some());
}
#[test]
fn matches_paths_across_separator_styles() {
// generate.py emits backslashes when it runs on Windows.
let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap();
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
let (files, exe) = merge(embedded, package);
assert_eq!(exe, "./acme.exe");
assert!(entry(&files, "./acme.exe").is_some());
assert!(entry(&files, ".\\rustdesk.exe").is_none());
}
}

View File

@@ -5,7 +5,7 @@ use std::{
process::{Command, Stdio},
};
use bin_reader::BinaryReader;
use bin_reader::{normalize_path, BinaryReader};
pub mod bin_reader;
#[cfg(windows)]
@@ -17,11 +17,24 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
const APP_METADATA: &[u8] = &[];
const APP_METADATA_CONFIG: &str = "meta.toml";
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
const META_LINE_PREFIX_FILE: &str = "file = ";
const APP_PREFIX: &str = "rustdesk";
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
#[cfg(windows)]
const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW";
// The extraction directory follows whatever executable the payload asks for, so a
// custom client gets its own directory instead of sharing RustDesk's. Falls back to
// APP_PREFIX when no package is injected, which keeps stock builds unchanged.
fn app_dir_name(exe: &str) -> String {
Path::new(&exe.replace('\\', "/"))
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.trim().to_lowercase())
.filter(|stem| !stem.is_empty())
.unwrap_or_else(|| APP_PREFIX.to_owned())
}
fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
return true;
@@ -50,13 +63,93 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
false
}
fn write_meta(dir: &Path, ts: u64) {
fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) {
let meta_file = dir.join(APP_METADATA_CONFIG);
if ts != 0 {
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
// Ignore is ok here
let _ = std::fs::write(meta_file, content);
let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts);
for path in package_paths {
content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path));
}
// Ignore is ok here
let _ = std::fs::write(meta_file, content);
}
fn previous_package_files(dir: &Path) -> Vec<String> {
let Ok(content) = std::fs::read_to_string(dir.join(APP_METADATA_CONFIG)) else {
return Vec::new();
};
content
.lines()
.filter_map(|line| line.strip_prefix(META_LINE_PREFIX_FILE))
.map(|path| path.trim().to_owned())
.collect()
}
// meta.toml is plain text in a user-writable directory, and it now drives deletion,
// so the path is rebuilt from plain components rather than joined as written. A
// prefix, root or parent component would otherwise escape the extraction directory:
// Path::join replaces the base entirely when given an absolute path.
fn resolve_within(dir: &Path, relative: &str) -> Option<PathBuf> {
use std::path::Component;
let mut path = dir.to_path_buf();
let mut any = false;
for component in Path::new(&relative.replace('\\', "/")).components() {
match component {
Component::Normal(part) => {
// A drive-relative name like "C:x" parses as Normal, and only a
// Windows host would classify "C:/..." as a Prefix, so the colon is
// rejected outright rather than relying on the host's parser.
if part.to_string_lossy().contains(':') {
return None;
}
path.push(part);
any = true;
}
Component::CurDir => {}
_ => return None,
}
}
if any {
Some(path)
} else {
None
}
}
// A customer who drops a branding asset gets a package without it, and the file
// would otherwise linger in an existing extraction and keep being used. The wipe
// cannot cover this: it is keyed on the packer's build timestamp, which is now the
// same for every customer of a release.
fn remove_dropped_package_files_with<F>(
dir: &Path,
current: &[String],
mut remove_file: F,
) -> Vec<String>
where
F: FnMut(&Path) -> std::io::Result<()>,
{
let keep: std::collections::HashSet<String> =
current.iter().map(|p| normalize_path(p)).collect();
let mut failed = Vec::new();
for previous in previous_package_files(dir) {
if keep.contains(&normalize_path(&previous)) {
continue;
}
let Some(path) = resolve_within(dir, &previous) else {
continue;
};
if path.is_file() {
println!("removing dropped {}", previous);
if let Err(error) = remove_file(&path) {
eprintln!("failed to remove dropped {}: {}", previous, error);
failed.push(previous);
}
}
}
failed
}
fn remove_dropped_package_files(dir: &Path, current: &[String]) -> Vec<String> {
remove_dropped_package_files_with(dir, current, |path| std::fs::remove_file(path))
}
fn setup(
@@ -71,7 +164,7 @@ fn setup(
} else {
// home dir
if let Some(dir) = dirs::data_local_dir() {
dir.join(APP_PREFIX)
dir.join(app_dir_name(&reader.exe))
} else {
eprintln!("not found data local dir");
return None;
@@ -87,10 +180,12 @@ fn setup(
}
std::fs::remove_dir_all(&dir).ok();
}
let mut metadata_paths = reader.package_paths.clone();
metadata_paths.extend(remove_dropped_package_files(&dir, &reader.package_paths));
for file in reader.files.iter() {
file.write_to_file(&dir);
}
write_meta(&dir, ts);
write_meta(&dir, ts, &metadata_paths);
#[cfg(windows)]
win::copy_runtime_broker(&dir);
#[cfg(linux)]
@@ -174,7 +269,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
}
}
fn main() {
fn main() -> Result<(), String> {
let mut args = Vec::new();
let mut arg_exe = Default::default();
let mut i = 0;
@@ -193,7 +288,7 @@ fn main() {
let quick_support = false;
let mut ui = false;
let reader = BinaryReader::default();
let reader = BinaryReader::new()?;
if let Some(exe) = setup(
reader,
None,
@@ -208,6 +303,7 @@ fn main() {
}
execute(exe, args, ui);
}
Ok(())
}
#[cfg(windows)]
@@ -246,3 +342,27 @@ mod win {
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
}
}
#[cfg(test)]
mod meta_tests {
use super::*;
#[test]
fn resolve_within_rejects_paths_that_escape() {
let base = Path::new("/base");
assert_eq!(
resolve_within(base, "./data/logo.png"),
Some(base.join("data").join("logo.png"))
);
assert_eq!(
resolve_within(base, ".\\data\\logo.png"),
Some(base.join("data").join("logo.png"))
);
// meta.toml is user-writable, so these must not reach remove_file.
assert_eq!(resolve_within(base, "../../etc/passwd"), None);
assert_eq!(resolve_within(base, "/etc/passwd"), None);
assert_eq!(resolve_within(base, "C:\\Windows\\System32\\x.dll"), None);
assert_eq!(resolve_within(base, "."), None);
assert_eq!(resolve_within(base, ""), None);
}
}

View File

@@ -37,7 +37,7 @@ serde = {version="1.0", features=["derive"]}
[dependencies.winapi]
version = "0.3"
default-features = true
features = ["dxgi", "dxgi1_2", "dxgi1_5", "dxgi1_6", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
features = ["dxgi", "dxgi1_2", "dxgi1_5", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
[target.'cfg(target_os = "macos")'.dependencies]
block = "0.1"

View File

@@ -132,7 +132,15 @@ impl Display {
.map(Display)
.collect::<Vec<_>>();
let displays_dxgi = Self::all_().unwrap_or(Default::default());
let mut displays_dxgi = match Self::all_() {
Ok(displays) => displays,
Err(e) => {
hbb_common::log::error!("DXGI display enumeration failed: {e}");
Vec::new()
}
};
// Win+P "Show only on 1/2" still enumerates detached DXGI outputs.
displays_dxgi.retain(|d| d.is_online() && d.width() > 0 && d.height() > 0);
// Return gdi displays if dxgi is not supported
if displays_dxgi.is_empty() {
@@ -155,7 +163,6 @@ impl Display {
}
// Reorder displays from dxgi
let mut displays_dxgi = displays_dxgi;
let mut displays_dxgi_ordered = Vec::new();
for name in names_gdi.iter() {
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
@@ -176,11 +183,11 @@ impl Display {
}
pub fn width(&self) -> usize {
self.0.width() as usize
self.0.width().max(0) as usize
}
pub fn height(&self) -> usize {
self.0.height() as usize
self.0.height().max(0) as usize
}
pub fn name(&self) -> String {
@@ -201,7 +208,8 @@ impl Display {
pub fn is_primary(&self) -> bool {
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
self.origin() == (0, 0)
// Detached outputs can still report origin (0,0) with a zero size.
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
}
#[cfg(feature = "vram")]

View File

@@ -1,629 +0,0 @@
//! HDR desktop -> SDR normalization for Desktop Duplication frames.
//!
//! With HDR enabled Windows composes the desktop as linear scRGB in
//! R16G16B16A16_FLOAT, and SDR "white" sits at the user's SDR content
//! brightness (DISPLAYCONFIG_SDR_WHITE_LEVEL) rather than at 1.0. The legacy
//! DuplicateOutput converts that to BGRA8 by clipping, which is the washed-out
//! picture reported for HDR hosts. This pass divides by the SDR white level,
//! clamps, and applies the sRGB transfer, so SDR content comes out exactly as
//! it would from an SDR desktop.
//!
//! It is a normalization, not a tone map: anything brighter than SDR white
//! (HDR video, HDR games) clips to white on the SDR viewer, where the local
//! HDR display would show it brighter than white. A roll-off would have to
//! move SDR white below 1.0 to make headroom, trading the accuracy of the SDR
//! content this pass exists for, so it is deliberately not done.
//!
//! Windows 11 22H2 also composes Advanced Color SDR (WCG) desktops in FP16,
//! but there 1.0 is the display's reference white rather than 80 nits and no
//! SDR white level applies. IDXGIOutput6 tells the two apart, and for a
//! non-HDR output the pass only applies the scRGB -> sRGB transfer.
//!
//! The conversion is automatic and stays on the controlled side on purpose:
//! the controller renders through Flutter external textures, which are 8-bit
//! on every desktop platform, so there is nothing to gain from sending HDR.
//! Real HDR pass-through, if the renderer ever supports it, should follow the
//! Sunshine/Moonlight pattern instead: an `hdr` capability bit advertised by
//! the controller behind an explicit user toggle, negotiated like i444.
use super::ComPtr;
use hbb_common::log;
use std::{
io, mem, ptr,
sync::{atomic::AtomicBool, OnceLock},
time::{Duration, Instant},
};
use winapi::{
ctypes::c_void,
shared::{
basetsd::SIZE_T,
dxgi::{CreateDXGIFactory1, IDXGIFactory1, IID_IDXGIFactory1, DXGI_OUTPUT_DESC},
dxgi1_2::IDXGIOutput1,
dxgi1_6::{IDXGIOutput6, IID_IDXGIOutput6, DXGI_OUTPUT_DESC1},
dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM,
dxgitype::{DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_SAMPLE_DESC},
minwindef::{FALSE, LPCVOID, UINT, ULONG},
ntdef::{LONG, LPCSTR, WCHAR},
winerror::S_OK,
},
um::{
d3d11::*,
d3dcommon::{ID3DBlob, ID3DInclude, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SHADER_MACRO},
libloaderapi::{GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32},
unknwnbase::IUnknown,
wingdi::{
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER,
DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
DISPLAYCONFIG_TOPOLOGY_ID,
},
winnt::HRESULT,
},
};
/// Set once the tone-map can never work in this process (no d3dcompiler, the
/// shaders do not compile). Capturers then stop asking DXGI for float frames.
/// Device-specific failures are not recorded here; the capturer that hit one
/// re-duplicates without the tone-map on its own.
pub static UNAVAILABLE: AtomicBool = AtomicBool::new(false);
/// Failures no capturer on this machine can recover from, as opposed to
/// device-specific ones that a recreated capturer may not hit again.
pub fn is_permanent(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::Unsupported
}
const VS_SRC: &str = "\
float4 main(uint id : SV_VertexID) : SV_Position {
float2 uv = float2((id << 1) & 2, id & 2);
return float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0);
}";
const PS_SRC: &str = "\
Texture2D<float4> src : register(t0);
cbuffer Params : register(b0) { float inv_sdr_white; float3 pad; };
float4 main(float4 pos : SV_Position) : SV_Target {
float3 lin = saturate(src.Load(int3(pos.xy, 0)).rgb * inv_sdr_white);
float3 lo = lin * 12.92;
float3 hi = 1.055 * pow(lin, 1.0 / 2.4) - 0.055;
return float4(lerp(hi, lo, step(lin, 0.0031308)), 1.0);
}";
const OUTPUT_STATE_REFRESH: Duration = Duration::from_secs(1);
pub struct HdrToSdr {
device: ComPtr<ID3D11Device>,
context: ComPtr<ID3D11DeviceContext>,
vs: ComPtr<ID3D11VertexShader>,
ps: ComPtr<ID3D11PixelShader>,
params: ComPtr<ID3D11Buffer>,
target: ComPtr<ID3D11Texture2D>,
rtv: ComPtr<ID3D11RenderTargetView>,
srv: ComPtr<ID3D11ShaderResourceView>,
// Texture `srv` was created for. The view keeps it alive, so the address
// cannot be recycled behind our back.
srv_source: *mut ID3D11Texture2D,
width: u32,
height: u32,
device_name: [WCHAR; 32],
// Advanced Color state is read from `output6`, which is re-enumerated from a
// fresh factory whenever `factory` stops being current.
factory: ComPtr<IDXGIFactory1>,
output6: ComPtr<IDXGIOutput6>,
is_hdr: bool,
// DISPLAYCONFIG units (1000 == 80 nits == scRGB 1.0). `None` when it could
// not be read, in which case 80 nits is assumed until it can.
sdr_white_level: Option<u32>,
queried_at: Instant,
}
impl HdrToSdr {
pub fn new(
device: *mut ID3D11Device,
context: *mut ID3D11DeviceContext,
output: *mut IDXGIOutput1,
device_name: &[WCHAR; 32],
) -> io::Result<Self> {
unsafe {
if device.is_null() || context.is_null() {
return Err(other("no d3d11 device"));
}
(*device).AddRef();
let device = ComPtr(device);
(*context).AddRef();
let context = ComPtr(context);
let compile = load_d3d_compile()?;
let vs_code = compile_shader(compile, VS_SRC, b"vs_4_0\0")?;
let ps_code = compile_shader(compile, PS_SRC, b"ps_4_0\0")?;
let mut vs = ptr::null_mut();
check(
(*device.0).CreateVertexShader(
(*vs_code.0).GetBufferPointer(),
(*vs_code.0).GetBufferSize(),
ptr::null_mut(),
&mut vs,
),
"CreateVertexShader",
)?;
let vs = ComPtr(vs);
let mut ps = ptr::null_mut();
check(
(*device.0).CreatePixelShader(
(*ps_code.0).GetBufferPointer(),
(*ps_code.0).GetBufferSize(),
ptr::null_mut(),
&mut ps,
),
"CreatePixelShader",
)?;
let ps = ComPtr(ps);
// Not found leaves the factory null, so the first refresh enumerates
// again instead of trusting the capturer's possibly stale output.
let (factory, mut output6) = enumerate_output6(device_name);
if output6.is_null() {
output6 = query_output6(output as *mut IUnknown);
}
// Float frames are only requested where IDXGIOutput6 exists, so an
// unreadable description still comes from an HDR-capable stack.
let is_hdr = output_is_hdr(output6.0).unwrap_or(true);
let sdr_white_level = if is_hdr {
query_sdr_white_level(device_name)
} else {
None
};
if is_hdr && sdr_white_level.is_none() {
log::warn!(
"HDR output but the SDR white level cannot be read (needs Windows 10 1709+), \
assuming 80 nits until it can"
);
}
let init = params_data(sdr_white_level);
let desc = D3D11_BUFFER_DESC {
ByteWidth: mem::size_of_val(&init) as _,
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_CONSTANT_BUFFER,
CPUAccessFlags: 0,
MiscFlags: 0,
StructureByteStride: 0,
};
let data = D3D11_SUBRESOURCE_DATA {
pSysMem: init.as_ptr() as _,
SysMemPitch: 0,
SysMemSlicePitch: 0,
};
let mut params = ptr::null_mut();
check(
(*device.0).CreateBuffer(&desc, &data, &mut params),
"CreateBuffer",
)?;
let params = ComPtr(params);
log::info!(
"scRGB desktop conversion ready, hdr {is_hdr}, sdr white level {sdr_white_level:?}"
);
Ok(Self {
device,
context,
vs,
ps,
params,
target: ComPtr(ptr::null_mut()),
rtv: ComPtr(ptr::null_mut()),
srv: ComPtr(ptr::null_mut()),
srv_source: ptr::null_mut(),
width: 0,
height: 0,
device_name: *device_name,
factory,
output6,
is_hdr,
sdr_white_level,
queried_at: Instant::now(),
})
}
}
/// Renders `source` (R16G16B16A16_FLOAT) into an owned B8G8R8A8_UNORM
/// texture of the same size and returns it. The texture stays valid until
/// the next call.
pub fn convert(
&mut self,
source: *mut ID3D11Texture2D,
desc: &D3D11_TEXTURE2D_DESC,
) -> io::Result<*mut ID3D11Texture2D> {
unsafe {
self.refresh_output_state();
self.ensure_target(desc.Width, desc.Height)?;
self.ensure_source_view(source)?;
let ctx = self.context.0;
let rtv = self.rtv.0;
let srv = self.srv.0;
let params = self.params.0;
let viewport = D3D11_VIEWPORT {
TopLeftX: 0.0,
TopLeftY: 0.0,
Width: self.width as f32,
Height: self.height as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
(*ctx).OMSetRenderTargets(1, &rtv, ptr::null_mut());
(*ctx).OMSetBlendState(ptr::null_mut(), &[0.0; 4], 0xffff_ffff);
(*ctx).OMSetDepthStencilState(ptr::null_mut(), 0);
(*ctx).RSSetState(ptr::null_mut());
(*ctx).RSSetViewports(1, &viewport);
(*ctx).IASetInputLayout(ptr::null_mut());
(*ctx).IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
(*ctx).VSSetShader(self.vs.0, ptr::null(), 0);
(*ctx).PSSetShader(self.ps.0, ptr::null(), 0);
(*ctx).PSSetConstantBuffers(0, 1, &params);
(*ctx).PSSetShaderResources(0, 1, &srv);
(*ctx).Draw(3, 0);
// Unbind so the next frame's copy and the encoder never see the
// target as a live render target or the desktop image as a bound
// shader input.
let no_srv: *mut ID3D11ShaderResourceView = ptr::null_mut();
(*ctx).PSSetShaderResources(0, 1, &no_srv);
(*ctx).OMSetRenderTargets(0, ptr::null(), ptr::null_mut());
Ok(self.target.0)
}
}
unsafe fn ensure_target(&mut self, width: u32, height: u32) -> io::Result<()> {
if !self.target.is_null() && self.width == width && self.height == height {
return Ok(());
}
let desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE,
CPUAccessFlags: 0,
MiscFlags: D3D11_RESOURCE_MISC_SHARED,
};
let mut target = ptr::null_mut();
check(
(*self.device.0).CreateTexture2D(&desc, ptr::null(), &mut target),
"CreateTexture2D",
)?;
let target = ComPtr(target);
let mut rtv = ptr::null_mut();
check(
(*self.device.0).CreateRenderTargetView(target.0 as *mut _, ptr::null(), &mut rtv),
"CreateRenderTargetView",
)?;
self.rtv = ComPtr(rtv);
self.target = target;
self.width = width;
self.height = height;
Ok(())
}
unsafe fn ensure_source_view(&mut self, source: *mut ID3D11Texture2D) -> io::Result<()> {
if !self.srv.is_null() && self.srv_source == source {
return Ok(());
}
let mut srv = ptr::null_mut();
check(
(*self.device.0).CreateShaderResourceView(source as *mut _, ptr::null(), &mut srv),
"CreateShaderResourceView",
)?;
self.srv = ComPtr(srv);
self.srv_source = source;
Ok(())
}
// Advanced Color state is dynamic: HDR can be switched on or off, or a WCG
// desktop can turn into an HDR one, without the duplication being lost. An
// output's description is a snapshot, so once the factory is no longer
// current a new factory and output are needed to see the new state, as the
// GetDesc1 docs require.
unsafe fn refresh_output_state(&mut self) {
if self.queried_at.elapsed() < OUTPUT_STATE_REFRESH {
return;
}
self.queried_at = Instant::now();
if self.factory.is_null() || (*self.factory.0).IsCurrent() == FALSE {
let (factory, output6) = enumerate_output6(&self.device_name);
if !output6.is_null() {
self.factory = factory;
self.output6 = output6;
} else {
// Keep reading the old output, but enumerate again next time.
self.factory = ComPtr(ptr::null_mut());
}
}
let is_hdr = output_is_hdr(self.output6.0).unwrap_or(self.is_hdr);
let level = if is_hdr {
query_sdr_white_level(&self.device_name)
} else {
None
};
// A transiently unreadable level keeps the last known one.
if is_hdr == self.is_hdr && (level.is_none() || level == self.sdr_white_level) {
return;
}
log::info!(
"output changed: hdr {} -> {is_hdr}, sdr white level {:?} -> {level:?}",
self.is_hdr,
self.sdr_white_level
);
if is_hdr && level.is_none() {
log::warn!(
"HDR output but the SDR white level cannot be read, assuming 80 nits until it can"
);
}
self.is_hdr = is_hdr;
self.sdr_white_level = level;
let data = params_data(level);
(*self.context.0).UpdateSubresource(
self.params.0 as *mut _,
0,
ptr::null(),
data.as_ptr() as _,
0,
0,
);
}
}
// `None` is either a non-HDR (WCG) output, where 1.0 already is the display's
// reference white, or an HDR output whose level is unknown; both use 1.0.
fn params_data(sdr_white_level: Option<u32>) -> [f32; 4] {
[
1000.0 / sdr_white_level.unwrap_or(1000) as f32,
0.0,
0.0,
0.0,
]
}
// FP16 desktop composition means either HDR (scene-referred, 1.0 == 80 nits)
// or, since Windows 11 22H2, Advanced Color SDR (display-referred), and only
// IDXGIOutput6 (Windows 10 1703) tells them apart. `None` when it cannot be
// read right now.
unsafe fn output_is_hdr(output6: *mut IDXGIOutput6) -> Option<bool> {
if output6.is_null() {
return None;
}
let mut desc: DXGI_OUTPUT_DESC1 = mem::zeroed();
if (*output6).GetDesc1(&mut desc) != S_OK {
return None;
}
Some(desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
}
unsafe fn query_output6(object: *mut IUnknown) -> ComPtr<IDXGIOutput6> {
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
if !object.is_null() {
(*object).QueryInterface(
&IID_IDXGIOutput6,
&mut output6 as *mut *mut _ as *mut *mut _,
);
}
ComPtr(output6)
}
// A fresh factory sees the current display configuration; the output is found
// by its GDI name because the outputs of a stale factory keep stale descriptions.
// Returns both or neither: a non-null factory guarantees the output came from
// its topology, so a caller that sees a null factory knows to enumerate again.
unsafe fn enumerate_output6(
device_name: &[WCHAR; 32],
) -> (ComPtr<IDXGIFactory1>, ComPtr<IDXGIOutput6>) {
let mut factory: *mut c_void = ptr::null_mut();
if CreateDXGIFactory1(&IID_IDXGIFactory1, &mut factory) != S_OK {
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
}
let factory = ComPtr(factory as *mut IDXGIFactory1);
let mut adapter_index = 0;
loop {
let mut adapter = ptr::null_mut();
if (*factory.0).EnumAdapters1(adapter_index, &mut adapter) != S_OK {
break;
}
let adapter = ComPtr(adapter);
adapter_index += 1;
let mut output_index = 0;
loop {
let mut output = ptr::null_mut();
if (*adapter.0).EnumOutputs(output_index, &mut output) != S_OK {
break;
}
let output = ComPtr(output);
output_index += 1;
let mut desc: DXGI_OUTPUT_DESC = mem::zeroed();
if (*output.0).GetDesc(&mut desc) == S_OK && wide_eq(&desc.DeviceName, device_name) {
let output6 = query_output6(output.0 as *mut IUnknown);
if output6.is_null() {
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
}
return (factory, output6);
}
}
}
(ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()))
}
fn other(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::Other, msg.into())
}
fn check(hr: HRESULT, what: &str) -> io::Result<()> {
if hr == S_OK {
Ok(())
} else {
Err(other(format!("{what} failed: {hr:#x}")))
}
}
// D3DCompile(pSrcData, SrcDataSize, pSourceName, pDefines, pInclude,
// pEntrypoint, pTarget, Flags1, Flags2, ppCode, ppErrorMsgs)
type D3DCompileFn = unsafe extern "system" fn(
LPCVOID,
SIZE_T,
LPCSTR,
*const D3D_SHADER_MACRO,
*mut ID3DInclude,
LPCSTR,
LPCSTR,
UINT,
UINT,
*mut *mut ID3DBlob,
*mut *mut ID3DBlob,
) -> HRESULT;
static D3D_COMPILE: OnceLock<Result<D3DCompileFn, String>> = OnceLock::new();
// Loaded once per process and kept: the compiler DLL is only needed on HDR
// desktops, and an import-time link would make every install depend on it.
fn load_d3d_compile() -> io::Result<D3DCompileFn> {
D3D_COMPILE
.get_or_init(|| unsafe { find_d3d_compile() })
.clone()
.map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))
}
unsafe fn find_d3d_compile() -> Result<D3DCompileFn, String> {
let name: Vec<u16> = "d3dcompiler_47.dll\0".encode_utf16().collect();
let module = LoadLibraryExW(name.as_ptr(), ptr::null_mut(), LOAD_LIBRARY_SEARCH_SYSTEM32);
if module.is_null() {
return Err("d3dcompiler_47.dll not available".into());
}
let f = GetProcAddress(module, b"D3DCompile\0".as_ptr() as _);
if f.is_null() {
return Err("D3DCompile not exported".into());
}
Ok(mem::transmute::<_, D3DCompileFn>(f))
}
unsafe fn compile_shader(
compile: D3DCompileFn,
src: &str,
target: &[u8],
) -> io::Result<ComPtr<ID3DBlob>> {
let mut code = ptr::null_mut();
let mut errors = ptr::null_mut();
let hr = compile(
src.as_ptr() as _,
src.len(),
ptr::null(),
ptr::null(),
ptr::null_mut(),
b"main\0".as_ptr() as _,
target.as_ptr() as _,
0,
0,
&mut code,
&mut errors,
);
let errors = ComPtr(errors);
if hr != S_OK || code.is_null() {
let msg = if errors.is_null() {
String::new()
} else {
let bytes = std::slice::from_raw_parts(
(*errors.0).GetBufferPointer() as *const u8,
(*errors.0).GetBufferSize(),
);
String::from_utf8_lossy(bytes).into_owned()
};
if !code.is_null() {
(*(code as *mut IUnknown)).Release();
}
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("D3DCompile failed: {hr:#x} {msg}"),
));
}
Ok(ComPtr(code))
}
const DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL: u32 = 11;
const QDC_ONLY_ACTIVE_PATHS: u32 = 2;
#[repr(C)]
#[allow(non_snake_case)]
struct DISPLAYCONFIG_SDR_WHITE_LEVEL {
header: DISPLAYCONFIG_DEVICE_INFO_HEADER,
SDRWhiteLevel: ULONG,
}
#[link(name = "user32")]
extern "system" {
fn GetDisplayConfigBufferSizes(
flags: u32,
numPathArrayElements: *mut u32,
numModeInfoArrayElements: *mut u32,
) -> LONG;
fn QueryDisplayConfig(
flags: u32,
numPathArrayElements: *mut u32,
pathArray: *mut DISPLAYCONFIG_PATH_INFO,
numModeInfoArrayElements: *mut u32,
modeInfoArray: *mut DISPLAYCONFIG_MODE_INFO,
currentTopologyId: *mut DISPLAYCONFIG_TOPOLOGY_ID,
) -> LONG;
fn DisplayConfigGetDeviceInfo(requestPacket: *mut DISPLAYCONFIG_DEVICE_INFO_HEADER) -> LONG;
}
/// SDR white level of the output whose GDI name is `device_name`
/// (e.g. `\\.\DISPLAY1`), in DISPLAYCONFIG units (1000 == 80 nits). `None`
/// when the query fails (before Windows 10 1709) or reports 0.
fn query_sdr_white_level(device_name: &[WCHAR; 32]) -> Option<u32> {
unsafe {
let mut n_paths = 0u32;
let mut n_modes = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut n_paths, &mut n_modes) != 0 {
return None;
}
let mut paths: Vec<DISPLAYCONFIG_PATH_INFO> = vec![mem::zeroed(); n_paths as usize];
let mut modes: Vec<DISPLAYCONFIG_MODE_INFO> = vec![mem::zeroed(); n_modes as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut n_paths,
paths.as_mut_ptr(),
&mut n_modes,
modes.as_mut_ptr(),
ptr::null_mut(),
) != 0
{
return None;
}
for path in &paths[..n_paths as usize] {
let mut source: DISPLAYCONFIG_SOURCE_DEVICE_NAME = mem::zeroed();
source.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
source.header.size = mem::size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as _;
source.header.adapterId = path.sourceInfo.adapterId;
source.header.id = path.sourceInfo.id;
if DisplayConfigGetDeviceInfo(&mut source.header) != 0
|| !wide_eq(&source.viewGdiDeviceName, device_name)
{
continue;
}
let mut white: DISPLAYCONFIG_SDR_WHITE_LEVEL = mem::zeroed();
white.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL;
white.header.size = mem::size_of::<DISPLAYCONFIG_SDR_WHITE_LEVEL>() as _;
white.header.adapterId = path.targetInfo.adapterId;
white.header.id = path.targetInfo.id;
if DisplayConfigGetDeviceInfo(&mut white.header) == 0 && white.SDRWhiteLevel != 0 {
return Some(white.SDRWhiteLevel);
}
}
None
}
}
fn wide_eq(a: &[WCHAR], b: &[WCHAR]) -> bool {
let end = |s: &[WCHAR]| s.iter().position(|&c| c == 0).unwrap_or(s.len());
a[..end(a)] == b[..end(b)]
}

View File

@@ -1,20 +1,18 @@
use std::{io, mem, ptr, slice};
pub mod gdi;
pub use gdi::CapturerGDI;
pub mod hdr;
pub mod mag;
use winapi::{
shared::{
dxgi::*,
dxgi1_2::*,
dxgi1_6::*,
dxgiformat::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT},
dxgitype::*,
minwindef::{DWORD, FALSE, TRUE, UINT},
ntdef::LONG,
windef::{HMONITOR, RECT},
winerror::*,
// dxgiformat::{DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE},
},
um::{
d3d11::*, d3dcommon::D3D_DRIVER_TYPE_UNKNOWN, unknwnbase::IUnknown, wingdi::*,
@@ -60,7 +58,6 @@ pub struct Capturer {
output_texture: bool,
adapter_desc1: DXGI_ADAPTER_DESC1,
rotate: Rotate,
hdr: Option<hdr::HdrToSdr>,
}
impl Capturer {
@@ -108,7 +105,7 @@ impl Capturer {
}
} else {
res = wrap_hresult(unsafe {
let hres = Self::duplicate_output(&display, device.0, &mut duplication);
let hres = (*display.inner.0).DuplicateOutput(device.0 as *mut _, &mut duplication);
if hres != S_OK {
gdi_capturer = display.create_gdi();
println!("Fallback to GDI");
@@ -164,8 +161,7 @@ impl Capturer {
device,
context,
duplication: ComPtr(duplication),
fastlane: desc.DesktopImageInSystemMemory == TRUE
&& desc.ModeDesc.Format != DXGI_FORMAT_R16G16B16A16_FLOAT,
fastlane: desc.DesktopImageInSystemMemory == TRUE,
surface: ComPtr(ptr::null_mut()),
texture: ComPtr(ptr::null_mut()),
width: display.width() as usize,
@@ -178,102 +174,9 @@ impl Capturer {
output_texture: false,
adapter_desc1,
rotate,
hdr: None,
})
}
// Asks for the float desktop that HDR mode composes so it can be tone-mapped;
// the legacy call would hand back DXGI's clipped BGRA8 conversion instead.
// Only where IDXGIOutput6 (Windows 10 1703) exists, since that is what later
// tells an HDR desktop from a WCG one; Microsoft's duplication sample gates
// the float request the same way.
unsafe fn duplicate_output(
display: &Display,
device: *mut ID3D11Device,
duplication: &mut *mut IDXGIOutputDuplication,
) -> HRESULT {
if !hdr::UNAVAILABLE.load(std::sync::atomic::Ordering::Relaxed) {
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
(*display.inner.0).QueryInterface(
&IID_IDXGIOutput6,
&mut output6 as *mut *mut _ as *mut *mut _,
);
if !output6.is_null() {
let output6 = ComPtr(output6);
let formats = [DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM];
let hres = (*output6.0).DuplicateOutput1(
device as *mut _,
0,
formats.len() as UINT,
formats.as_ptr(),
duplication,
);
if hres == S_OK {
return hres;
}
hbb_common::log::warn!(
"HDR DuplicateOutput1 failed: hr={:#x}, fallback=DuplicateOutput",
hres as u32
);
}
}
(*display.inner.0).DuplicateOutput(device as *mut _, duplication)
}
unsafe fn tonemap(
&mut self,
source: *mut ID3D11Texture2D,
desc: &D3D11_TEXTURE2D_DESC,
) -> io::Result<*mut ID3D11Texture2D> {
if self.hdr.is_none() {
match hdr::HdrToSdr::new(
self.device.0,
self.context.0,
self.display.inner.0,
&self.display.desc.DeviceName,
) {
Ok(hdr) => self.hdr = Some(hdr),
Err(err) => return self.abandon_tonemap(err),
}
}
let converted = match self.hdr.as_mut() {
Some(hdr) => hdr.convert(source, desc),
None => Err(io::Error::new(io::ErrorKind::Other, "no tone-map")),
};
match converted {
Ok(texture) => Ok(texture),
Err(err) => self.abandon_tonemap(err),
}
}
// Drops the tone-map and re-duplicates the output the legacy way, so DXGI
// hands over clipped BGRA8 (the pre-HDR behaviour). If re-duplication fails,
// switch to GDI before returning. The caller sees WouldBlock and asks again.
unsafe fn abandon_tonemap<T>(&mut self, err: io::Error) -> io::Result<T> {
if hdr::is_permanent(&err) {
hdr::UNAVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed);
}
hbb_common::log::error!("HDR tone-map failed, re-duplicating without it: {err}");
self.hdr = None;
(*self.duplication.0).ReleaseFrame();
self.duplication = ComPtr(ptr::null_mut());
let mut duplication = ptr::null_mut();
let result = wrap_hresult(
(*self.display.inner.0).DuplicateOutput(self.device.0 as *mut _, &mut duplication),
);
if let Err(err) = result {
if self.set_gdi() {
return Err(io::ErrorKind::WouldBlock.into());
}
return Err(err);
}
self.duplication = ComPtr(duplication);
let mut desc: DXGI_OUTDUPL_DESC = mem::zeroed();
(*duplication).GetDesc(&mut desc);
self.fastlane = desc.DesktopImageInSystemMemory == TRUE;
Err(io::ErrorKind::WouldBlock.into())
}
fn create_rotations(
device: *mut ID3D11Device,
context: *mut ID3D11DeviceContext,
@@ -427,9 +330,6 @@ impl Capturer {
}
unsafe fn load_frame(&mut self, timeout: UINT) -> io::Result<(*const u8, i32)> {
if self.duplication.0.is_null() {
return Err(io::ErrorKind::AddrNotAvailable.into());
}
let mut frame = ptr::null_mut();
#[allow(invalid_value)]
let mut info = mem::MaybeUninit::uninit().assume_init();
@@ -465,12 +365,6 @@ impl Capturer {
let mut texture_desc = mem::MaybeUninit::uninit().assume_init();
(*texture.0).GetDesc(&mut texture_desc);
let mut source = texture.0;
if texture_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
source = self.tonemap(texture.0, &texture_desc)?;
(*source).GetDesc(&mut texture_desc);
}
texture_desc.Usage = D3D11_USAGE_STAGING;
texture_desc.BindFlags = 0;
texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
@@ -491,7 +385,7 @@ impl Capturer {
&mut surface as *mut *mut _ as *mut *mut _,
);
(*self.context.0).CopyResource(readable.0 as *mut _, source as *mut _);
(*self.context.0).CopyResource(readable.0 as *mut _, texture.0 as *mut _);
Ok(surface)
}
@@ -598,14 +492,6 @@ impl Capturer {
let texture = ComPtr(texture);
self.texture = texture;
let mut frame_desc: D3D11_TEXTURE2D_DESC = mem::zeroed();
(*self.texture.0).GetDesc(&mut frame_desc);
if frame_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
let converted = self.tonemap(self.texture.0, &frame_desc)?;
(*converted).AddRef();
self.texture = ComPtr(converted);
}
let mut final_texture = self.texture.0 as *mut c_void;
let mut rotation = match self.display.rotation() {
DXGI_MODE_ROTATION_ROTATE90 => 90,
@@ -689,9 +575,6 @@ impl Capturer {
}
fn unmap(&self) {
if self.duplication.0.is_null() {
return;
}
unsafe {
(*self.duplication.0).ReleaseFrame();
if self.fastlane {

View File

@@ -297,6 +297,30 @@ pub fn clear_wayland_displays_cache() {
// capturer rebuild loop clears about once a second.
}
// Bumped ONLY by the layout-drift edge in display_service (its single owner), never by cache
// clears: session inits and hotplug workers clear the cache too, and a bump there tears down
// every OTHER live capturer on a multi-display session. A capturer records this at build and
// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation
// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886).
static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Whether no snapshot has been cached: the signature of an enumeration that failed at session
/// build (an `Err` is deliberately not cached), as opposed to a session that started healthy.
#[cfg(feature = "drm")]
pub fn wayland_snapshot_missing() -> bool {
DISPLAYS.lock().unwrap().is_none()
}
#[cfg(any(test, feature = "drm"))]
pub fn bump_layout_generation() {
SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
}
#[cfg(feature = "drm")]
pub fn wayland_snapshot_generation() -> u64 {
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire)
}
// Return (min_x, max_x, min_y, max_y)
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
let wayland_displays = get_displays();
@@ -332,7 +356,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
// Otherwise, we use the logical size for `uinput`.
if displays.len() == 1 {
let d = &displays[0];
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
let (w, h) = oriented_physical(d);
return Some((d.x, d.x + w, d.y, d.y + h));
}
let mut min_x = i32::MAX;
@@ -344,6 +369,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
min_y = min_y.min(d.y);
let size = if let Some(logical_size) = d.logical_size {
logical_size
} else if d.transform == 90 || d.transform == 270 {
oriented_physical(d)
} else {
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
// This may occur if the Wayland compositor does not provide logical size information,
@@ -374,6 +401,24 @@ pub struct DisplayRect {
pub y: i32,
pub w: i32,
pub h: i32,
// Carried so the drift comparison sees 0<->180 and 90<->270 flips, whose rects are
// otherwise identical; the remap itself matches by name and containment, never by this.
pub transform: i32,
}
/// Physical size in delivered orientation: a 90/270 output scans out WxH but is captured,
/// advertised and pointed at as HxW.
fn oriented_physical(d: &WaylandDisplayInfo) -> (i32, i32) {
if d.transform == 90 || d.transform == 270 {
(d.height, d.width)
} else {
(d.width, d.height)
}
}
/// The logical rectangles of a display list, for a caller that already has the list.
pub fn logical_rects_of_displays(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
logical_rects_of(displays)
}
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
@@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
.iter()
.map(|d| {
let (w, h) = if single {
(d.width, d.height)
oriented_physical(d)
} else {
d.logical_size.unwrap_or((d.width, d.height))
d.logical_size.unwrap_or_else(|| oriented_physical(d))
};
DisplayRect {
name: d.name.clone(),
@@ -396,6 +441,7 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
y: d.y,
w,
h,
transform: d.transform,
}
})
.collect()
@@ -495,8 +541,8 @@ mod tests {
#[test]
fn test_clear_keeps_the_failure_stamp() {
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
// and dropping the stamp with it would defeat the backoff. Sole test touching these
// statics; serialize before adding another.
// and dropping the stamp with it would defeat the backoff. The generation test also
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
clear_wayland_displays_cache();
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
@@ -519,6 +565,7 @@ mod tests {
height,
logical_size,
refresh_rate: 60,
transform: 0,
}
}
@@ -553,6 +600,42 @@ mod tests {
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
}
#[test]
fn a_single_rotated_display_swaps_the_uinput_rect() {
// Review finding 1 on rustdesk#15889: the single-display branch served the unrotated
// mode, so the pointer could not reach ~44% of a portrait screen.
let mut d = display(0, 0, 1920, 1080, None);
d.transform = 90;
assert_eq!(desktop_rect_of(&[d.clone()]), Some((0, 1080, 0, 1920)));
let rects = logical_rects_of(&[d]);
assert_eq!((rects[0].w, rects[0].h), (1080, 1920));
}
#[test]
fn a_transform_flip_is_visible_to_the_drift_comparison() {
// Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform
// field is what lets `baseline != live` fire on them.
let mut a = display(0, 0, 1920, 1080, Some((1920, 1080)));
let mut b = a.clone();
a.transform = 90;
b.transform = 270;
assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b]));
}
#[test]
fn only_the_explicit_bump_moves_the_generation() {
// A cache clear must NOT bump: session inits clear too, and a bump there rebuilds
// every other live capturer (adversarial finding on the first version of this).
let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
clear_wayland_displays_cache();
assert_eq!(
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
before
);
bump_layout_generation();
assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before);
}
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
DisplayRect {
name: name.to_owned(),
@@ -560,6 +643,7 @@ mod tests {
y,
w,
h,
transform: 0,
}
}

417
res/admin-roles.py Executable file
View File

@@ -0,0 +1,417 @@
#!/usr/bin/env python3
import argparse
import json
import requests
ROLE_TYPES = {
"global": 1,
"individual": 2,
"group": 3,
}
PERMISSION_IDS = {
"users.view": 0x0101,
"users.create": 0x0103,
"users.invite": 0x0104,
"users.delete": 0x0105,
"users.enable_disable": 0x0106,
"users.edit_email": 0x0107,
"users.edit_password": 0x0108,
"users.edit_note": 0x0109,
"users.manage_2fa": 0x010A,
"users.force_logout": 0x010B,
"users.change_group": 0x010C,
"users.change_strategy": 0x010D,
"users.change_control_role": 0x010E,
"users.edit_display_name": 0x010F,
"devices.view": 0x0201,
"devices.enable_disable": 0x0203,
"devices.delete": 0x0204,
"devices.edit_info": 0x0205,
"devices.assign_to_user": 0x0206,
"devices.change_group": 0x0207,
"devices.change_strategy": 0x0208,
"user_groups.view": 0x0301,
"user_groups.edit": 0x0302,
"device_groups.view": 0x0401,
"device_groups.edit": 0x0402,
"device_groups.change_strategy": 0x0403,
"audits.view": 0x0501,
"audits.edit": 0x0502,
"strategies.view": 0x0601,
"strategies.edit": 0x0602,
"custom_clients.view": 0x0701,
"custom_clients.edit": 0x0702,
"control_roles.view": 0x0801,
"control_roles.edit": 0x0802,
}
PERMISSION_NAMES = {permission_id: name for name, permission_id in PERMISSION_IDS.items()}
def check_response(response):
if response.status_code != 200:
print(f"Error: HTTP {response.status_code}: {response.text}")
exit(1)
if response.text and response.text.strip():
try:
data = response.json()
except ValueError:
return response.text
if isinstance(data, dict) and "error" in data:
print(f"Error: {data['error']}")
exit(1)
return data
return None
def headers_with(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def split_csv(value):
if value is None:
return None
return [item.strip() for item in value.split(",") if item.strip()]
def parse_permissions(value):
permissions = []
for item in split_csv(value) or []:
permission = PERMISSION_IDS.get(item.lower())
if permission is None:
try:
permission = int(item, 0)
except ValueError:
print(f"Error: Invalid permission name or ID '{item}'")
exit(1)
if permission < 0 or permission > 65535:
print(f"Error: Permission ID '{item}' is outside the 0-65535 range")
exit(1)
permissions.append(permission)
return permissions
def format_role_permissions(role):
permissions = role.get("permissions")
if isinstance(permissions, list):
role["permissions"] = [
PERMISSION_NAMES.get(permission, permission) for permission in permissions
]
return role
def list_roles(url, token, name=None, role_type=None, page_size=50):
params = {"pageSize": page_size}
if name is not None:
params["name"] = name
if role_type is not None:
params["type"] = ROLE_TYPES[role_type]
roles = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/admin-roles", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing admin roles")
exit(1)
rows = data.get("data", [])
roles.extend(format_role_permissions(role) for role in rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return roles
def get_role(url, token, name=None, guid=None):
if guid:
response = requests.get(
f"{url}/api/admin-roles/{guid}", headers=headers_with(token)
)
role = check_response(response)
if isinstance(role, dict):
return format_role_permissions(role)
return role
roles = list_roles(url, token, name=name)
for role in roles:
if role.get("name") == name:
return role
return None
def resolve_role(url, token, name=None, guid=None):
role = get_role(url, token, name=name, guid=guid)
if role:
return role
target = guid if guid else name
print(f"Error: Admin role '{target}' not found")
exit(1)
def get_user_guid(url, token, name):
response = requests.get(
f"{url}/api/users",
headers=headers_with(token),
params={"name": name, "pageSize": 50, "current": 1},
)
data = check_response(response)
users = data.get("data", []) if isinstance(data, dict) else []
for user in users:
if user.get("name") == name:
return user.get("guid")
return None
def resolve_users(url, token, users):
guids = []
for user in users:
if len(user) == 36 and user.count("-") == 4:
guids.append(user)
continue
guid = get_user_guid(url, token, user)
if not guid:
print(f"Error: User '{user}' not found")
exit(1)
guids.append(guid)
return guids
def create_role(
url,
token,
name,
role_type,
permissions,
note=None,
user_groups=None,
device_groups=None,
unassigned=None,
):
payload = {
"name": name,
"type": ROLE_TYPES[role_type],
"permissions": permissions,
}
if note is not None:
payload["note"] = note
if user_groups:
payload["user_groups"] = user_groups
if device_groups:
payload["device_groups"] = device_groups
if unassigned is not None:
payload["unassigned"] = unassigned
response = requests.post(
f"{url}/api/admin-roles", headers=headers_with(token), json=payload
)
check_response(response)
def update_role(
url,
token,
guid,
new_name=None,
note=None,
permissions=None,
user_groups=None,
device_groups=None,
unassigned=None,
):
payload = {}
if new_name is not None:
payload["name"] = new_name
if note is not None:
payload["note"] = note
if permissions is not None:
payload["permissions"] = permissions
if user_groups is not None:
payload["user_groups"] = user_groups
if device_groups is not None:
payload["device_groups"] = device_groups
if unassigned is not None:
payload["unassigned"] = unassigned
response = requests.put(
f"{url}/api/admin-roles/{guid}", headers=headers_with(token), json=payload
)
check_response(response)
def delete_roles(url, token, guids):
response = requests.delete(
f"{url}/api/admin-roles",
headers=headers_with(token),
json={"guids": guids},
)
check_response(response)
def change_users(url, token, guid, users, remove=False):
method = requests.delete if remove else requests.post
response = method(
f"{url}/api/admin-roles/{guid}/users",
headers=headers_with(token),
json={"users": users},
)
check_response(response)
def view_users(url, token, role_guid, page_size=50):
params = {"admin_role_guid": role_guid, "pageSize": page_size}
users = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/users", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing users")
exit(1)
rows = data.get("data", [])
users.extend(rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return users
def require_role_target(parser, args):
if not args.name and not args.guid:
parser.error("one of --name or --guid is required")
def main():
parser = argparse.ArgumentParser(description="Admin role manager")
parser.add_argument(
"command",
choices=["view", "add", "update", "delete", "view-users", "add-users", "remove-users"],
)
parser.add_argument("--url", required=True, help="Server URL")
parser.add_argument("--token", required=True, help="API token")
parser.add_argument("--name", help="Admin role name")
parser.add_argument("--guid", help="Admin role GUID")
parser.add_argument("--new-name", help="New admin role name")
parser.add_argument("--note", help="Role note; use an empty value to clear it")
parser.add_argument("--type", choices=ROLE_TYPES, help="Role type")
parser.add_argument(
"--permissions",
help="Comma-separated permission names or numeric IDs; use an empty value to clear",
)
parser.add_argument(
"--user-groups",
help="Comma-separated user group names; use an empty value to clear",
)
parser.add_argument(
"--device-groups",
help="Comma-separated device group names; use an empty value to clear",
)
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
unassigned = parser.add_mutually_exclusive_group()
unassigned.add_argument(
"--unassigned", dest="unassigned", action="store_true", help="Include unassigned devices"
)
unassigned.add_argument(
"--no-unassigned",
dest="unassigned",
action="store_false",
help="Exclude unassigned devices",
)
parser.set_defaults(unassigned=None)
args = parser.parse_args()
args.url = args.url.rstrip("/")
if args.command == "view":
if args.guid:
result = resolve_role(args.url, args.token, guid=args.guid)
else:
result = list_roles(args.url, args.token, args.name, args.type)
print(json.dumps(result, indent=2))
return
if args.command == "add":
if not args.name or not args.type or args.permissions is None:
parser.error("--name, --type, and --permissions are required for add")
if args.type != "group" and (
args.user_groups is not None
or args.device_groups is not None
or args.unassigned is not None
):
parser.error("group scope options can only be used with --type group")
create_role(
args.url,
args.token,
args.name,
args.type,
parse_permissions(args.permissions),
args.note,
split_csv(args.user_groups),
split_csv(args.device_groups),
args.unassigned,
)
print(f"Success: Created admin role '{args.name}'")
return
require_role_target(parser, args)
role = resolve_role(args.url, args.token, args.name, args.guid)
role_guid = role.get("guid")
role_name = role.get("name")
if args.command == "update":
updates = [
args.new_name,
args.note,
args.permissions,
args.user_groups,
args.device_groups,
args.unassigned,
]
if all(value is None for value in updates):
parser.error("at least one update option is required")
if role.get("type") != ROLE_TYPES["group"] and (
args.user_groups is not None
or args.device_groups is not None
or args.unassigned is not None
):
parser.error("group scope options can only be used with a group role")
update_role(
args.url,
args.token,
role_guid,
args.new_name,
args.note,
parse_permissions(args.permissions) if args.permissions is not None else None,
split_csv(args.user_groups),
split_csv(args.device_groups),
args.unassigned,
)
print(f"Success: Updated admin role '{role_name}'")
elif args.command == "delete":
delete_roles(args.url, args.token, [role_guid])
print(f"Success: Deleted admin role '{role_name}'")
elif args.command == "view-users":
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
elif args.command in ("add-users", "remove-users"):
users = split_csv(args.users)
if not users:
parser.error("--users is required for add-users and remove-users")
user_guids = resolve_users(args.url, args.token, users)
remove = args.command == "remove-users"
change_users(args.url, args.token, role_guid, user_guids, remove=remove)
action = "Removed users from" if remove else "Added users to"
print(f"Success: {action} admin role '{role_name}'")
if __name__ == "__main__":
main()

292
res/control-roles.py Executable file
View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
import argparse
import json
import requests
STATUSES = {
"disabled": 0,
"enabled": 1,
}
def check_response(response):
if response.status_code != 200:
print(f"Error: HTTP {response.status_code}: {response.text}")
exit(1)
if response.text and response.text.strip():
try:
data = response.json()
except ValueError:
return response.text
if isinstance(data, dict) and "error" in data:
print(f"Error: {data['error']}")
exit(1)
return data
return None
def headers_with(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def split_csv(value):
if value is None:
return None
return [item.strip() for item in value.split(",") if item.strip()]
def list_roles(url, token, name=None, status=None, page_size=50):
params = {"pageSize": page_size}
if name is not None:
params["name"] = name
if status is not None:
params["status"] = STATUSES[status]
roles = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/control-roles", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing control roles")
exit(1)
rows = data.get("data", [])
for role in rows:
role.pop("info", None)
roles.extend(rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return roles
def get_role(url, token, name=None, guid=None):
if guid:
response = requests.get(
f"{url}/api/control-roles/{guid}", headers=headers_with(token)
)
role = check_response(response)
if isinstance(role, dict):
role.pop("info", None)
return role
roles = list_roles(url, token, name=name)
for role in roles:
if role.get("name") == name:
return role
return None
def resolve_role(url, token, name=None, guid=None):
role = get_role(url, token, name=name, guid=guid)
if role:
return role
target = guid if guid else name
print(f"Error: Control role '{target}' not found")
exit(1)
def get_user_guid(url, token, name):
response = requests.get(
f"{url}/api/users",
headers=headers_with(token),
params={"name": name, "pageSize": 50, "current": 1},
)
data = check_response(response)
users = data.get("data", []) if isinstance(data, dict) else []
for user in users:
if user.get("name") == name:
return user.get("guid")
return None
def resolve_users(url, token, users):
guids = []
for user in users:
if len(user) == 36 and user.count("-") == 4:
guids.append(user)
continue
guid = get_user_guid(url, token, user)
if not guid:
print(f"Error: User '{user}' not found")
exit(1)
guids.append(guid)
return guids
def create_role(url, token, name, note=None):
payload = {"name": name}
if note is not None:
payload["note"] = note
response = requests.post(
f"{url}/api/control-roles", headers=headers_with(token), json=payload
)
check_response(response)
def update_role(url, token, guid, new_name=None, note=None):
payload = {}
if new_name is not None:
payload["name"] = new_name
if note is not None:
payload["note"] = note
response = requests.put(
f"{url}/api/control-roles/{guid}", headers=headers_with(token), json=payload
)
check_response(response)
def delete_roles(url, token, guids):
response = requests.delete(
f"{url}/api/control-roles",
headers=headers_with(token),
json={"guids": guids},
)
check_response(response)
def set_status(url, token, guids, disable):
response = requests.put(
f"{url}/api/control-roles/enable",
headers=headers_with(token),
json={"guids": guids, "disable": disable},
)
check_response(response)
def change_users(url, token, guid, users, remove=False):
if remove:
endpoint = f"{url}/api/control-roles/users"
response = requests.delete(
endpoint,
headers=headers_with(token),
json={"user_guids": users},
)
else:
endpoint = f"{url}/api/control-roles/{guid}/users"
response = requests.post(
endpoint,
headers=headers_with(token),
json={"user_guids": users},
)
check_response(response)
def view_users(url, token, role_guid, page_size=50):
params = {"control_role_guid": role_guid, "pageSize": page_size}
users = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/users", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing users")
exit(1)
rows = data.get("data", [])
users.extend(rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return users
def require_role_target(parser, args):
if not args.name and not args.guid:
parser.error("one of --name or --guid is required")
def main():
parser = argparse.ArgumentParser(
description="Control role manager (configure control permissions in the web console)"
)
parser.add_argument(
"command",
choices=[
"view",
"add",
"update",
"delete",
"enable",
"disable",
"view-users",
"assign-users",
"remove-users",
],
)
parser.add_argument("--url", required=True, help="Server URL")
parser.add_argument("--token", required=True, help="API token")
parser.add_argument("--name", help="Control role name")
parser.add_argument("--guid", help="Control role GUID")
parser.add_argument("--new-name", help="New control role name")
parser.add_argument("--note", help="Role note; use an empty value to clear it")
parser.add_argument("--status", choices=STATUSES, help="Status filter for view")
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
args = parser.parse_args()
args.url = args.url.rstrip("/")
if args.command == "view":
if args.guid:
result = resolve_role(args.url, args.token, guid=args.guid)
else:
result = list_roles(args.url, args.token, args.name, args.status)
print(json.dumps(result, indent=2))
return
if args.command == "add":
if not args.name:
parser.error("--name is required for add")
create_role(args.url, args.token, args.name, args.note)
print(f"Success: Created control role '{args.name}'")
return
if args.command == "remove-users":
users = split_csv(args.users)
if not users:
parser.error("--users is required for remove-users")
user_guids = resolve_users(args.url, args.token, users)
change_users(args.url, args.token, None, user_guids, remove=True)
print("Success: Removed users from their control roles")
return
require_role_target(parser, args)
role = resolve_role(args.url, args.token, args.name, args.guid)
role_guid = role.get("guid")
role_name = role.get("name")
if args.command == "update":
if args.new_name is None and args.note is None:
parser.error("--new-name or --note is required for update")
update_role(args.url, args.token, role_guid, args.new_name, args.note)
print(f"Success: Updated control role '{role_name}'")
elif args.command == "delete":
delete_roles(args.url, args.token, [role_guid])
print(f"Success: Deleted control role '{role_name}'")
elif args.command in ("enable", "disable"):
disable = args.command == "disable"
set_status(args.url, args.token, [role_guid], disable)
print(f"Success: {args.command.title()}d control role '{role_name}'")
elif args.command == "view-users":
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
elif args.command == "assign-users":
users = split_csv(args.users)
if not users:
parser.error("--users is required for assign-users")
user_guids = resolve_users(args.url, args.token, users)
change_users(args.url, args.token, role_guid, user_guids)
print(f"Success: Assigned users to control role '{role_name}'")
if __name__ == "__main__":
main()

View File

@@ -18,6 +18,9 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
namespace RemotePrinter
{
VOID installUpdatePrinter(const std::wstring& installFolder);
VOID uninstallPrinter();
// `appName` names the printer and its port. It is passed in rather than compiled
// in so that a single dll serves every custom client; an empty value keeps the
// stock "RustDesk Printer" name.
VOID installUpdatePrinter(const std::wstring& installFolder, const std::wstring& appName);
VOID uninstallPrinter(const std::wstring& appName);
}

View File

@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
{
do
{
if (lstrcmpW(processName, processEntry.szExeFile) == 0)
if (lstrcmpiW(processName, processEntry.szExeFile) == 0)
{
HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID);
if (process != NULL)
@@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter(
DWORD er = ERROR_SUCCESS;
int nResult = 0;
LPWSTR installFolder = NULL;
LPWSTR pwz = NULL;
LPWSTR pwzData = NULL;
std::wstring appNameValue;
std::wstring installFolderValue;
hr = WcaInitialize(hInstall, "InstallPrinter");
ExitOnFailure(hr, "Failed to initialize");
@@ -1031,12 +1031,27 @@ UINT __stdcall InstallPrinter(
hr = WcaGetProperty(L"CustomActionData", &pwzData);
ExitOnFailure(hr, "failed to get CustomActionData");
pwz = pwzData;
hr = WcaReadStringFromCaData(&pwz, &installFolder);
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
// "<app name>|<install folder>". Split here rather than through
// WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a
// Formatted property value cannot carry.
{
std::wstring data(pwzData);
size_t separator = data.find(L'|');
if (separator == std::wstring::npos)
{
// A package built before the name was passed in; keep the stock name.
appNameValue.clear();
installFolderValue = data;
}
else
{
appNameValue = data.substr(0, separator);
installFolderValue = data.substr(separator + 1);
}
}
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
RemotePrinter::installUpdatePrinter(installFolder);
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
LExit:
@@ -1054,14 +1069,30 @@ UINT __stdcall UninstallPrinter(
HRESULT hr = S_OK;
DWORD er = ERROR_SUCCESS;
LPWSTR pwzData = NULL;
std::wstring appNameValue;
hr = WcaInitialize(hInstall, "UninstallPrinter");
ExitOnFailure(hr, "Failed to initialize");
// Must match the name install used, otherwise the printer is left behind. Absent
// on packages built before this was passed in, where it was the stock name.
hr = WcaGetProperty(L"CustomActionData", &pwzData);
ExitOnFailure(hr, "failed to get CustomActionData");
if (pwzData)
{
appNameValue = pwzData;
}
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
RemotePrinter::uninstallPrinter();
RemotePrinter::uninstallPrinter(appNameValue);
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
LExit:
if (pwzData) {
ReleaseStr(pwzData);
}
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}

View File

@@ -18,12 +18,19 @@ namespace RemotePrinter
{
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
// The driver files and the driver name ship with the app under their stock names
// and stay fixed for every custom client. Only the printer and its port carry the
// app name, and that arrives at runtime so one dll serves every custom client.
LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf";
LPCWCH RD_PRINTER_PORT = L"RustDesk Printer";
LPCWCH RD_PRINTER_NAME = L"RustDesk Printer";
LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver";
LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk";
LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
static std::wstring printerNameOf(const std::wstring &appName)
{
return (appName.empty() ? std::wstring(RD_DEFAULT_APP_NAME) : appName) + L" Printer";
}
using FuncEnum = std::function<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
template <typename T, typename R>
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
@@ -458,8 +465,12 @@ namespace RemotePrinter
// We should not check the driver version because the driver is deployed with the application.
// It's better to uninstall the existing driver and install the driver from the application.
// 3. Add the printer.
VOID installUpdatePrinter(const std::wstring &installFolder)
VOID installUpdatePrinter(const std::wstring &installFolder, const std::wstring &appName)
{
const std::wstring printerName = printerNameOf(appName);
const LPCWCH RD_PRINTER_NAME = printerName.c_str();
const LPCWCH RD_PRINTER_PORT = printerName.c_str();
const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
if (!FileExists(infFile))
{
@@ -505,13 +516,15 @@ namespace RemotePrinter
}
}
VOID uninstallPrinter()
VOID uninstallPrinter(const std::wstring &appName)
{
deletePrinter(RD_PRINTER_NAME);
const std::wstring printerName = printerNameOf(appName);
deletePrinter(printerName.c_str());
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
uninstallDriver(RD_PRINTER_DRIVER_NAME);
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
checkDeleteLocalPort(RD_PRINTER_PORT);
checkDeleteLocalPort(printerName.c_str());
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
}
}

View File

@@ -30,7 +30,14 @@
<CustomAction Id="SetPropertyServiceStop.SetParam.PropertyName" Return="check" Property="PropertyName" Value="STOP_SERVICE" />
<CustomAction Id="TryDeleteStartupShortcut.SetParam" Return="check" Property="ShortcutName" Value="$(var.Product) Tray" />
<CustomAction Id="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" Value="[INSTALLFOLDER_INNER]" />
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
<!-- The app name comes first and is separated by '|', which cannot occur in a
Windows path nor in a validated app name. wcautil's own delimiter is a
literal wide char 128 that a Formatted value cannot carry, and [~] is
MSI's NUL escape rather than that delimiter, so the action parses this
itself. Passing the name keeps the dll free of it, so one build serves
every custom client. -->
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[ProductName]|[INSTALLFOLDER_INNER]" />
<CustomAction Id="UninstallPrinter.SetParam" Return="check" Property="UninstallPrinter" Value="[ProductName]" />
<InstallExecuteSequence>
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
@@ -86,6 +93,7 @@
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT &gt;= 603" />
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT &gt;= 603" />
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>

View File

@@ -13,6 +13,12 @@
<PropertyRef Id="AddRemovePropertiesFile" />
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
<!--$Media2Start$-->
<!-- preprocess.py in template mode adds a second cabinet here, holding only
the files that differ per customer, so a custom client can be produced by
rebuilding that small cabinet instead of the whole package. The shipped
msi is built without template mode and keeps a single cabinet. -->
<!--$Media2End$-->
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />

View File

@@ -10,7 +10,6 @@ import subprocess
import re
import platform
from pathlib import Path
from itertools import chain
import shutil
from xml.sax.saxutils import quoteattr
@@ -67,6 +66,14 @@ def make_parser():
parser.add_argument(
"-c", "--custom", action="store_true", help="Is custom client", default=False
)
parser.add_argument(
"--template",
action="store_true",
default=False,
help="Build a template to be patched per customer rather than a finished "
"package: puts the files a custom client replaces in their own cabinet, so "
"rebranding rebuilds a few hundred KB instead of the whole payload.",
)
parser.add_argument(
"--conn-type",
type=str,
@@ -92,6 +99,43 @@ def make_parser():
return parser
# Files a custom client replaces. Kept in their own cabinet by --template so that
# rebranding rebuilds a few hundred KB instead of recompressing the whole payload.
# The app executable is handled separately: it has its own component in RustDesk.wxs.
#
# A template has to ship a placeholder for each of these so there is a File row to
# patch, but the branding assets are optional for a customer and a stock build has
# none of them at all. So each optional one installs only when its property is set,
# which the patcher does for the files a customer actually supplied. Otherwise a
# customer without a logo would install the placeholder, where today they get no
# logo at all -- the client treats a missing asset as "no logo".
PER_CUSTOMER_DISK_ID = 2
PER_CUSTOMER_FILES = {
# relative path -> property gating installation, or None if always installed
"custom.txt": None,
"data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO",
"data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG",
"data/flutter_assets/assets/logo.png": "CC_HAS_LOGO",
"data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT",
"data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK",
}
def normalize_relative(relative_path):
path = relative_path.replace("\\", "/")
while path.startswith("./"):
path = path[2:]
return path.lower()
def is_per_customer(relative_path):
return normalize_relative(relative_path) in PER_CUSTOMER_FILES
def per_customer_condition(relative_path):
return PER_CUSTOMER_FILES.get(normalize_relative(relative_path))
def read_lines_and_start_index(file_path, tag_start, tag_end):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
@@ -112,7 +156,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
return lines, index_start
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False):
indent = g_indent_unit * 3
path = Path(dist_dir)
idx = 1
@@ -126,12 +170,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
if subdir != ".":
dir_attr = f'Subdirectory="{subdir}"'
relative = file_path.relative_to(path).as_posix()
disk_attr = ""
condition_attr = ""
if template and is_per_customer(relative):
disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"'
# Branding assets are optional, and the template only carries a
# placeholder, so install one only when the customer supplied it.
condition = per_customer_condition(relative)
if condition:
condition_attr = f' Condition="{condition} = 1"'
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
# because it will cause error
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
to_insert_lines = f"""
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
{indent}</Component>
"""
lines.insert(index_start + 1, to_insert_lines[1:])
@@ -140,17 +195,52 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
return True
def gen_auto_component(app_name, dist_dir):
def gen_auto_component(app_name, dist_dir, template=False):
return gen_content_between_tags(
"Package/Components/RustDesk.wxs",
"<!--$AutoComonentStart$-->",
"<!--$AutoComponentEnd$-->",
lambda lines, index_start: insert_components_between_tags(
lines, index_start, app_name, dist_dir
lines, index_start, app_name, dist_dir, template
),
)
def gen_media2():
"""Second cabinet holding only what a custom client replaces."""
def func(lines, index_start):
indent = g_indent_unit * 2
lines.insert(
index_start + 1,
f'{indent}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
' EmbedCab="yes" CompressionLevel="high" />\n',
)
return lines
return gen_content_between_tags(
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", func
)
def put_app_exe_on_media2():
"""The app executable has its own component, so it is moved by name."""
target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs")
with open(target, "r", encoding="utf-8") as f:
content = f.read()
old = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
new = (
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
)
if content.count(old) != 1:
print(f"Error: expected exactly one App.exe File element, found {content.count(old)}")
return False
with open(target, "w", encoding="utf-8") as f:
f.write(content.replace(old, new))
return True
def gen_pre_vars(args, dist_dir):
def func(lines, index_start):
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
@@ -190,18 +280,6 @@ def replace_app_name_in_langs(app_name):
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
def replace_app_name_in_custom_actions(app_name):
custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions")
for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
for i, line in enumerate(lines):
line = re.sub(r"\bRustDesk\b", app_name, line)
line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver")
lines[i] = line
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
def gen_upgrade_info():
def func(lines, index_start):
indent = g_indent_unit * 3
@@ -478,11 +556,16 @@ if __name__ == "__main__":
if not gen_conn_type(args):
sys.exit(-1)
if not gen_auto_component(app_name, dist_dir):
if args.template:
if not gen_media2():
sys.exit(-1)
if not put_app_exe_on_media2():
sys.exit(-1)
if not gen_auto_component(app_name, dist_dir, args.template):
sys.exit(-1)
if not gen_custom_dialog_bitmaps():
sys.exit(-1)
replace_app_name_in_langs(args.app_name)
replace_app_name_in_custom_actions(args.app_name)

View File

@@ -87,7 +87,7 @@ if(VCPKG_HOST_IS_WINDOWS)
vcpkg_acquire_msys(MSYS_ROOT PACKAGES automake1.16)
set(SHELL "${MSYS_ROOT}/usr/bin/bash.exe")
vcpkg_add_to_path("${MSYS_ROOT}/usr/share/automake-1.16")
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX}")
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX} ")
else()
find_program(SHELL bash)
endif()

View File

@@ -1753,6 +1753,10 @@ pub struct LoginConfigHandler {
pub remember: bool,
config: PeerConfig,
pub port_forward: (String, i32),
/// Held by a port-forward mapping from filling `port_forward` and `hash`
/// until its login is built from them; a window's mappings log in
/// concurrently.
pub(crate) port_forward_login_turn: Arc<hbb_common::tokio::sync::Mutex<()>>,
pub version: i64,
features: Option<Features>,
pub session_id: u64, // used for local <-> server communication
@@ -1792,6 +1796,10 @@ impl Deref for LoginConfigHandler {
}
impl LoginConfigHandler {
pub(crate) fn set_hash(&mut self, hash: Hash) {
self.hash = hash;
}
/// Initialize the login config handler.
///
/// # Arguments

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "قفل اللوحة"),
("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"),
("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "تفعيل"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Заблакіраваць палатно"),
("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"),
("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Уключыць"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Заключване на платното"),
("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"),
("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Активирай"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Bloca el llenç"),
("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"),
("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Habilita"),
].iter().cloned().collect();
}

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
("screenshot-action-tip", "请选择如何继续截屏。"),
("Save as", "另存为"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Export", "导出"),
("Export Logs", "导出日志"),
("Import Folder", "导入文件夹"),
("Copy to clipboard", "复制到剪贴板"),
("Enable remote printer", "启用远程打印机"),
("Downloading {}", "正在下载 {}"),
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "锁定画布"),
("Sync clipboard between sessions", "在会话间同步剪贴板"),
("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", "允许终端应用复制到剪贴板"),
("Enable", "启用"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Zamknout zobrazení"),
("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"),
("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Povolit"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Lås lærred"),
("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"),
("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Aktivér"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Sichtfeld sperren"),
("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"),
("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Aktivieren"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Κλείδωμα καμβά"),
("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"),
("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Ενεργοποίηση"),
].iter().cloned().collect();
}

View File

@@ -276,5 +276,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("whitelist_cidr_tip", "CIDR notation is supported, e.g. 192.168.1.0/24"),
("Your ip is blocked by the peer", "Your IP is blocked by the peer"),
("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."),
("terminal-clipboard-write-tip", "An app in the terminal wants to copy text to this device's clipboard. If granted, this permission applies to terminal apps in all connections until you turn it off in Settings. Manual copy and paste are unaffected."),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Ŝlosi kanvason"),
("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"),
("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Ebligi"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Bloquear lienzo"),
("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"),
("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Habilitar"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Lukusta lõuend"),
("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"),
("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Luba"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Blokeatu oihala"),
("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"),
("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Gaitu"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "قفل کردن صفحه"),
("Sync clipboard between sessions", "همگام‌سازی کلیپ‌بورد بین نشست‌ها"),
("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی می‌شوند به کلیپ‌بورد سایر نشست‌های متصل شما نیز ارسال می‌شوند."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "فعال‌سازی"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Lukitse näkymä"),
("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"),
("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Ota käyttöön"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Verrouiller la vue"),
("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"),
("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Activer"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "ტილოს დაბლოკვა"),
("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"),
("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "ჩართვა"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "કેનવાસ લોક કરો"),
("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"),
("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "સક્ષમ કરો"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "נעל לוח ציור"),
("Sync clipboard between sessions", "סנכרן לוח בין סשנים"),
("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "הפעל"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "कैनवास लॉक करें"),
("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"),
("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "सक्षम करें"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Zaključaj pozadinu"),
("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"),
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Omogući"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Nézet zárolása"),
("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"),
("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Engedélyezés"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Kunci kanvas"),
("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"),
("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Aktifkan"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Blocca tela"),
("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"),
("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Abilita"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "キャンバスをロック"),
("Sync clipboard between sessions", "セッション間でクリップボードを同期"),
("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "有効にする"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "캔버스 잠금"),
("Sync clipboard between sessions", "세션 간 클립보드 동기화"),
("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "활성화"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Кенепті құлыптау"),
("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"),
("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Қосу"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Užrakinti drobę"),
("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"),
("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Įgalinti"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Bloķēt audeklu"),
("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"),
("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Iespējot"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"),
("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"),
("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "അനുവദിക്കുക"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Lås lerret"),
("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"),
("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Aktiver"),
].iter().cloned().collect();
}

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."),
("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."),
("Save as", "Opslaan als"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Export", "Exporteren"),
("Export Logs", "Logboeken exporteren"),
("Import Folder", "Map importeren"),
("Copy to clipboard", "Kopiëren naar het klembord"),
("Enable remote printer", "Printer op afstand inschakelen"),
("Downloading {}", "Downloaden {}"),
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Canvas vergrendelen"),
("Sync clipboard between sessions", "Klembord synchroniseren tussen sessies"),
("sync-clipboard-between-sessions-tip", "Tekst of afbeeldingen die in één externe sessie worden gekopieerd, worden ook naar het klembord van uw andere verbonden sessies gestuurd."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Inschakelen"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Zablokuj ekran"),
("Sync clipboard between sessions", "Synchronizuj schowek między sesjami"),
("sync-clipboard-between-sessions-tip", "Tekst lub obrazy skopiowane w jednej sesji zdalnej są wysyłane także do schowka pozostałych połączonych sesji."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Włącz"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Bloquear tela"),
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
("sync-clipboard-between-sessions-tip", "O texto ou as imagens copiados numa sessão remota também são enviados para a área de transferência das suas outras sessões ligadas."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Ativar"),
].iter().cloned().collect();
}

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
("Save as", "Salvar como"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Export", "Exportar"),
("Export Logs", "Exportar logs"),
("Import Folder", "Importar pasta"),
("Copy to clipboard", "Copiar para área de transferência"),
("Enable remote printer", "Habilitar impressora remota"),
("Downloading {}", "Baixando {}"),
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Bloquear tela"),
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Habilitar"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Blochează ecranul"),
("Sync clipboard between sessions", "Sincronizează clipboardul între sesiuni"),
("sync-clipboard-between-sessions-tip", "Textul sau imaginile copiate într-o sesiune la distanță sunt trimise și în clipboardul celorlalte sesiuni conectate."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Activează"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Заблокировать холст"),
("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"),
("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Включить"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Bloca sa tela"),
("Sync clipboard between sessions", "Sincroniza sa punta de billete intre is sessiones"),
("sync-clipboard-between-sessions-tip", "Su testu o is immàgines copiadas in una sessione remota sunt imbiadas fintzas a sa punta de billete de is àteras sessiones connètidas."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Abìlita"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Uzamknúť zobrazenie"),
("Sync clipboard between sessions", "Synchronizovať schránku medzi reláciami"),
("sync-clipboard-between-sessions-tip", "Text alebo obrázky skopírované v jednej vzdialenej relácii sa odošlú aj do schránky ostatných pripojených relácií."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Povoliť"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Zakleni platno"),
("Sync clipboard between sessions", "Sinhroniziraj odložišče med sejami"),
("sync-clipboard-between-sessions-tip", "Besedilo ali slike, kopirane v eni oddaljeni seji, se pošljejo tudi v odložišče vaših drugih povezanih sej."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Omogoči"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Kyç canvas"),
("Sync clipboard between sessions", "Sinkronizo clipboard-in midis sesioneve"),
("sync-clipboard-between-sessions-tip", "Teksti ose imazhet e kopjuara në një sesion të largët dërgohen edhe në clipboard-in e sesioneve të tjera të lidhura."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Aktivizo"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Zaključaj pozadinu"),
("Sync clipboard between sessions", "Sinhronizuj klipbord između sesija"),
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirane u jednoj udaljenoj sesiji šalju se i u klipbord vaših ostalih povezanih sesija."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Omogući"),
].iter().cloned().collect();
}

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Sammanslagning av skärmdumpar från flera skärmar stöds för närvarande inte. Byt till en enda skärm och försök igen."),
("screenshot-action-tip", "Välj hur du vill fortsätta med skärmdumpen."),
("Save as", "Spara som"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Export", "Exportera"),
("Export Logs", "Exportera loggar"),
("Import Folder", "Importera mapp"),
("Copy to clipboard", "Kppiera till urklipp"),
("Enable remote printer", "Aktivera fjärrskrivare"),
("Downloading {}", "Laddar ner {}"),
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Lås canvas"),
("Sync clipboard between sessions", "Synkronisera urklipp mellan sessioner"),
("sync-clipboard-between-sessions-tip", "Text eller bilder som kopieras i en fjärrsession skickas även till urklipp i dina andra anslutna sessioner."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Aktivera"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "கேன்வாஸைப் பூட்டு"),
("Sync clipboard between sessions", "அமர்வுகளுக்கு இடையே கிளிப்போர்டை ஒத்திசைக்கவும்"),
("sync-clipboard-between-sessions-tip", "ஒரு தொலை அமர்வில் நகலெடுக்கப்பட்ட உரை அல்லது படங்கள் உங்கள் பிற இணைக்கப்பட்ட அமர்வுகளின் கிளிப்போர்டுக்கும் அனுப்பப்படும்."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "இயக்கு"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", ""),
("Sync clipboard between sessions", ""),
("sync-clipboard-between-sessions-tip", ""),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", ""),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "ล็อคแคนวาส"),
("Sync clipboard between sessions", "ซิงค์คลิปบอร์ดระหว่างเซสชัน"),
("sync-clipboard-between-sessions-tip", "ข้อความหรือรูปภาพที่คัดลอกในเซสชันระยะไกลหนึ่งจะถูกส่งไปยังคลิปบอร์ดของเซสชันอื่นที่เชื่อมต่ออยู่ด้วย"),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "เปิดใช้งาน"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Tuvali kilitle"),
("Sync clipboard between sessions", "Oturumlar arasında panoyu senkronize et"),
("sync-clipboard-between-sessions-tip", "Bir uzak oturumda kopyalanan metin veya görseller, bağlı diğer oturumlarınızın panosuna da gönderilir."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Etkinleştir"),
].iter().cloned().collect();
}

View File

@@ -187,7 +187,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Enter your password", "輸入您的密碼"),
("Logging in...", "正在登入..."),
("Enable RDP session sharing", "啟用 RDP 工作階段分享"),
("Auto Login", "自動登入 (只在您設定「工作階段結束後鎖定」時有效)"),
("Auto Login", "自動登入只在您設定「工作階段結束後鎖定」時有效"),
("Enable direct IP access", "啟用 IP 直接存取"),
("Rename", "重新命名"),
("Space", "空白"),
@@ -300,7 +300,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Language", "語言"),
("Keep RustDesk background service", "保持 RustDesk 後台服務"),
("Ignore Battery Optimizations", "忽略電池最佳化"),
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」"),
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」"),
("Start on boot", "開機時啟動"),
("Start the screen sharing service on boot, requires special permissions", "開機時啟動螢幕分享服務,需要特殊權限。"),
("Connection not allowed", "不允許連線"),
@@ -519,11 +519,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("I Agree", "同意"),
("Decline", "拒絕"),
("Timeout in minutes", "超時(分鐘)"),
("auto_disconnect_option_tip", "自動在連入的使用者不活躍時關閉工作階段"),
("auto_disconnect_option_tip", "自動關閉不活躍的連入工作階段"),
("Connection failed due to inactivity", "由於長時間沒有操作,已自動關閉工作階段"),
("Check for software update on startup", "啟動時檢查更新"),
("upgrade_rustdesk_server_pro_to_{}_tip", "請升級專業版伺服器到{}或更高版本!"),
("pull_group_failed_tip", "獲取群組訊息失敗"),
("pull_group_failed_tip", "重新整理群組失敗"),
("Filter by intersection", "按照交集篩選"),
("Remove wallpaper during incoming sessions", "在接受連入連線時移除桌布"),
("Test", "測試"),
@@ -639,7 +639,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Use D3D rendering", "使用 D3D 渲染"),
("Printer", "印表機"),
("printer-os-requirement-tip", "印表機的傳出功能需要 Windows 10 或更高版本。"),
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此設備"),
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此裝置"),
("printer-{}-not-installed-tip", "{} 印表機未安裝。"),
("printer-{}-ready-tip", "{} 印表機已安裝,您可以使用列印功能了。"),
("Install {} Printer", "安裝 {} 印表機"),
@@ -659,17 +659,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"),
("screenshot-action-tip", "請選擇要如何處理這張截圖。"),
("Save as", "另存為"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Export", "匯出"),
("Export Logs", "匯出日誌"),
("Import Folder", "匯入資料夾"),
("Copy to clipboard", "複製到剪貼簿"),
("Enable remote printer", "啟用遠端列印"),
("Downloading {}", "正在下載 {} 並安裝新版本。"),
("{} Update", "{} 更新"),
("{}-to-update-tip", "即將關閉 {} 並安裝新版本。"),
("download-new-version-failed-tip", "下載失敗,您可以重試或點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("download-new-version-failed-tip", "下載失敗,您可以重試或點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("Auto update", "自動更新"),
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("websocket_tip", "使用 WebSocket 時,只支援使用中繼連接。"),
("Use WebSocket", "使用 WebSocket"),
("Trackpad speed", "觸控板速度"),
@@ -680,7 +680,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("View camera", "檢視相機"),
("Enable camera", "允許查看鏡頭"),
("No cameras", "沒有鏡頭"),
("view_camera_unsupported_tip", "您的遠端設備不支援查看鏡頭"),
("view_camera_unsupported_tip", "您的遠端裝置不支援查看鏡頭"),
("Terminal", "終端機"),
("Enable terminal", "啟用終端機"),
("New tab", "新分頁"),
@@ -690,7 +690,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Failed to get user token.", "取得使用者權杖失敗"),
("Incorrect username or password.", "使用者名稱或密碼不正確"),
("The user is not an administrator.", "使用者並不是系統管理員"),
("Failed to check if the user is an administrator.", "檢查使用者是否系統管理員時失敗了"),
("Failed to check if the user is an administrator.", "無法確認使用者是否系統管理員"),
("Supported only in the installed version.", "僅支援於已安裝的版本"),
("elevation_username_tip", "輸入使用者名稱或網域\\使用者名稱"),
("Preparing for installation ...", "正在準備安裝..."),
@@ -747,21 +747,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Show monitor switch button on the main toolbar", "在主工具列上顯示螢幕切換按鈕"),
("Show on the minimized toolbar", "在最小化工具列上顯示"),
("All monitors", "所有顯示器"),
("#{} monitor", "{}號顯示器"),
("conn-e2ee-unavailable-tip", "無法驗證端端加密。\n遠端裝置可能仍在準備中,請稍後試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
("#{} monitor", "{} 號顯示器"),
("conn-e2ee-unavailable-tip", "無法驗證端端加密。\n遠端裝置可能仍在準備中,請稍後試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
("ID whitelisting", "ID 白名單"),
("Use ID whitelisting", "只允許白名單上的 ID 進行連線"),
("id_whitelist_tip", "只有白名單上的 ID 可以存取"),
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 符合任意數量的字元,'?' 符合單一字元"),
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 符合任意數量的字元,'?' 符合單一字元"),
("Invalid ID", "ID 無效"),
("Your ID is blocked by the peer", "的 ID 已被對方封鎖"),
("Your ip is blocked by the peer", "的 IP 已被對方封鎖"),
("id_whitelist_caveat_tip", "ID 由對端戶端回報白名單用於減少暴露面,不能取代密碼或 2FA"),
("Your ID is blocked by the peer", "的 ID 已被對方封鎖"),
("Your ip is blocked by the peer", "的 IP 已被對方封鎖"),
("id_whitelist_caveat_tip", "ID 由對端戶端回報。此白名單用於減少暴露面,不能取代密碼或 2FA"),
("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"),
("Continue", "繼續"),
("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"),
("Lock canvas", "鎖定畫布"),
("Sync clipboard between sessions", "在工作階段間同步剪貼簿"),
("sync-clipboard-between-sessions-tip", "在一個遠端工作階段中複製的文字或圖片也會傳送到其他已連線工作階段的剪貼簿。"),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "啟用"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Блокування полотна"),
("Sync clipboard between sessions", "Синхронізувати буфер обміну між сеансами"),
("sync-clipboard-between-sessions-tip", "Текст або зображення, скопійовані в одному віддаленому сеансі, також надсилаються до буфера обміну інших підключених сеансів."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Увімкнути"),
].iter().cloned().collect();
}

View File

@@ -745,6 +745,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "display-name"),
("password-hidden-tip", ""),
("preset-password-in-use-tip", ""),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Khóa khung hình"),
("Sync clipboard between sessions", "Đồng bộ clipboard giữa các phiên"),
("sync-clipboard-between-sessions-tip", "Văn bản hoặc hình ảnh được sao chép trong một phiên từ xa cũng được gửi đến clipboard của các phiên đã kết nối khác."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("Enable", "Bật"),
].iter().cloned().collect();
}

View File

@@ -1496,8 +1496,8 @@ pub fn rename_exe_cmd(src_exe: &str, path: &str) -> ResultType<String> {
.ok_or(anyhow!("Can't get file name of {src_exe}"))?
.to_string_lossy()
.to_string();
let app_name = crate::get_app_name().to_lowercase();
if src_exe_filename.to_lowercase() == format!("{app_name}.exe") {
let app_name = crate::get_app_name();
if src_exe_filename == format!("{app_name}.exe") {
Ok("".to_owned())
} else {
Ok(format!(

View File

@@ -92,12 +92,11 @@ pub async fn listen(
tokio::select! {
Ok((forward, addr)) = listener.accept() => {
log::info!("new connection from {:?}", addr);
lc.write().unwrap().port_forward = (remote_host.clone(), remote_port);
let id = id.clone();
let password = password.clone();
let mut forward = Framed::new(forward, BytesCodec::new());
let mut close_port_forward = false;
match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward).await {
match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward, &remote_host, remote_port).await {
Ok(Some(stream)) => {
let interface = interface.clone();
tokio::spawn(async move {
@@ -143,6 +142,8 @@ async fn connect_and_login(
token: &str,
is_rdp: bool,
close_port_forward: &mut bool,
remote_host: &str,
remote_port: i32,
) -> ResultType<Option<Stream>> {
let conn_type = if is_rdp {
ConnType::RDP
@@ -160,6 +161,8 @@ async fn connect_and_login(
}
let mut buffer = Vec::new();
let mut received = false;
let mut challenge = None;
let mut pending_login = None;
let _keep_it = hc_connection(feedback, rendezvous_server, token).await;
@@ -177,7 +180,8 @@ async fn connect_and_login(
let msg_in = Message::parse_from_bytes(&bytes)?;
match msg_in.union {
Some(message::Union::Hash(hash)) => {
if !interface.handle_hash(password, hash, &mut stream).await {
challenge = Some(hash.clone());
if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, &mut stream).await {
return Ok(None);
}
}
@@ -208,9 +212,10 @@ async fn connect_and_login(
},
d = ui_receiver.recv() => {
match d {
Some(Data::Login((os_username, os_password, password, remember))) => {
interface.handle_login_from_ui(os_username, os_password, password, remember, &mut stream).await;
}
Some(Data::Login(login)) => match &challenge {
Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, &mut stream).await,
None => pending_login = Some(login),
},
Some(Data::Message(msg)) => {
allow_err!(stream.send(&msg).await);
}
@@ -233,6 +238,76 @@ async fn connect_and_login(
Ok(Some(stream))
}
/// A mapping's login is built from the window's shared handler:
/// `create_login_msg` reads `port_forward` and `handle_login_from_ui` reads
/// `hash`. Mappings log in concurrently, so each fills them and sends under
/// the window's turn lock, or one login carried another mapping's target or
/// answered another's challenge.
async fn login_with_hash(
interface: &impl Interface,
password: &str,
hash: Hash,
remote_host: &str,
remote_port: i32,
stream: &mut Stream,
) -> bool {
let lc = interface.get_lch();
let turn = lc.read().unwrap().port_forward_login_turn.clone();
let _turn = turn.lock().await;
lc.write().unwrap().port_forward = (remote_host.to_owned(), remote_port);
interface.handle_hash(password, hash, stream).await
}
type UiLogin = (String, String, String, bool);
/// This connection's `Hash`. The window's password prompt is broadcast to
/// every mapping and can reach this one first, so a password typed while
/// the `Hash` was on its way is kept and answers it now, rather than being
/// dropped in the hope that the mapping which prompted has already stored
/// it in the shared handler.
async fn hash_arrived(
interface: &impl Interface,
password: &str,
hash: Hash,
pending_login: Option<UiLogin>,
remote_host: &str,
remote_port: i32,
stream: &mut Stream,
) -> bool {
match pending_login {
Some(login) => {
login_from_ui(interface, &hash, login, remote_host, remote_port, stream).await;
true
}
None => login_with_hash(interface, password, hash, remote_host, remote_port, stream).await,
}
}
/// The window's password prompt is broadcast to every mapping; this one
/// answers it with its own challenge.
async fn login_from_ui(
interface: &impl Interface,
hash: &Hash,
login: UiLogin,
remote_host: &str,
remote_port: i32,
stream: &mut Stream,
) {
let lc = interface.get_lch();
let turn = lc.read().unwrap().port_forward_login_turn.clone();
let _turn = turn.lock().await;
{
let mut lc = lc.write().unwrap();
lc.port_forward = (remote_host.to_owned(), remote_port);
lc.set_hash(hash.clone());
}
let (os_username, os_password, password, remember) = login;
interface
.handle_login_from_ui(os_username, os_password, password, remember, stream)
.await;
}
async fn run_forward(forward: Framed<TcpStream, BytesCodec>, stream: Stream) -> ResultType<()> {
log::info!("new port forwarding connection started");
let mut forward = forward;
@@ -257,3 +332,172 @@ async fn run_forward(forward: Framed<TcpStream, BytesCodec>, stream: Stream) ->
}
Ok(())
}
#[cfg(test)]
mod login_tests {
use super::*;
use async_trait::async_trait;
use hbb_common::{
tcp::FramedStream,
tokio::time::{sleep, Duration},
};
use sha2::{Digest, Sha256};
/// A window's interface over its shared handler. `handle_hash` can pause
/// before building the login, where the real one looks passwords up.
#[derive(Clone)]
struct Ui {
lc: Arc<RwLock<LoginConfigHandler>>,
pause: Duration,
}
#[async_trait]
impl Interface for Ui {
fn send(&self, _data: Data) {}
fn msgbox(&self, _msgtype: &str, _title: &str, _text: &str, _link: &str) {}
fn handle_login_error(&self, _err: &str) -> bool {
false
}
fn handle_peer_info(&self, _pi: PeerInfo) {}
fn set_multiple_windows_session(&self, _sessions: Vec<WindowsSession>) {}
async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool {
sleep(self.pause).await;
crate::client::handle_hash(self.lc.clone(), pass, hash, self, peer).await
}
async fn handle_login_from_ui(
&self,
os_username: String,
os_password: String,
password: String,
remember: bool,
peer: &mut Stream,
) {
crate::client::handle_login_from_ui(
self.lc.clone(),
os_username,
os_password,
password,
remember,
peer,
)
.await
}
async fn handle_test_delay(&self, _t: TestDelay, _peer: &mut Stream) {}
fn get_lch(&self) -> Arc<RwLock<LoginConfigHandler>> {
self.lc.clone()
}
}
fn window() -> Ui {
let mut lc = LoginConfigHandler::default();
lc.conn_type = ConnType::PORT_FORWARD;
Ui {
lc: Arc::new(RwLock::new(lc)),
pause: Duration::ZERO,
}
}
/// (our end, the peer's end) of one connection.
async fn loopback() -> (Stream, Stream) {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
let (server, _) = l.accept().await.unwrap();
(
Stream::Tcp(FramedStream::from(client, addr)),
Stream::Tcp(FramedStream::from(server, addr)),
)
}
async fn login_at(peer: &mut Stream) -> LoginRequest {
let bytes = peer.next().await.unwrap().unwrap();
Message::parse_from_bytes(&bytes)
.unwrap()
.login_request()
.clone()
}
fn target(lr: &LoginRequest) -> (String, i32) {
(lr.port_forward().host.clone(), lr.port_forward().port)
}
fn hash(challenge: &str) -> Hash {
Hash {
salt: "salt".to_owned(),
challenge: challenge.to_owned(),
..Default::default()
}
}
/// What the peer expects for password `pw` under `hash(challenge)`.
fn digest(challenge: &str) -> Vec<u8> {
let mut h = Sha256::new();
h.update("pw");
h.update("salt");
let salted = h.finalize();
let mut h2 = Sha256::new();
h2.update(&salted[..]);
h2.update(challenge);
h2.finalize()[..].to_vec()
}
#[test]
fn mappings_logging_in_at_once_each_carry_their_own_target() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let mut ui = window();
ui.pause = Duration::from_millis(50);
let (mut a, mut a_peer) = loopback().await;
let (mut b, mut b_peer) = loopback().await;
tokio::join!(
login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a),
login_with_hash(&ui, "pw", hash("b"), "b", 2, &mut b),
);
assert_eq!(target(&login_at(&mut a_peer).await), ("a".to_owned(), 1));
assert_eq!(target(&login_at(&mut b_peer).await), ("b".to_owned(), 2));
});
}
#[test]
fn a_mapping_answers_the_prompt_with_its_own_challenge() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let ui = window();
let (mut a, mut a_peer) = loopback().await;
let (mut b, mut b_peer) = loopback().await;
// A's hash arrived last, so it is the one the handler holds.
assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a).await);
login_at(&mut a_peer).await;
let typed = (String::new(), String::new(), "pw".to_owned(), false);
login_from_ui(&ui, &hash("b"), typed, "b", 2, &mut b).await;
let lr = login_at(&mut b_peer).await;
assert_eq!(lr.password, digest("b"));
assert_eq!(target(&lr), ("b".to_owned(), 2));
});
}
#[test]
fn a_password_typed_before_this_connections_hash_answers_it_when_it_comes() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let ui = window();
let (mut b, mut b_peer) = loopback().await;
// The prompt's password reached B before its hash, and no other
// mapping has stored it in the handler yet.
let typed = (String::new(), String::new(), "pw".to_owned(), false);
assert!(hash_arrived(&ui, "", hash("b"), Some(typed), "b", 2, &mut b).await);
let lr = login_at(&mut b_peer).await;
assert_eq!(lr.password, digest("b"));
assert_eq!(target(&lr), ("b".to_owned(), 2));
});
}
}

View File

@@ -585,13 +585,9 @@ impl Connection {
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
let mut last_recv_time = Instant::now();
conn.stream.set_send_timeout(
if conn.file_transfer.is_some() || conn.port_forward_socket.is_some() || conn.terminal {
SEND_TIMEOUT_OTHER
} else {
SEND_TIMEOUT_VIDEO
},
);
// The connection type is not known until the login request arrives;
// `on_message` picks the type-specific timeout then.
conn.stream.set_send_timeout(SEND_TIMEOUT_VIDEO);
#[cfg(not(any(target_os = "android", target_os = "ios")))]
std::thread::spawn(move || Self::handle_input(_rx_input, tx_cloned));
@@ -1091,9 +1087,6 @@ impl Connection {
}
}
video_service::notify_video_frame_fetched_by_conn_id(id, None);
if conn.authorized {
password::update_temporary_password();
}
if let Err(err) = conn.try_port_forward_loop(&mut rx_from_cm).await {
conn.on_close(&err.to_string(), false).await;
raii::AuthedConnID::check_remove_session(conn.inner.id(), conn.session_key());
@@ -1751,6 +1744,10 @@ impl Connection {
return false;
}
self.authorized = true;
// One-time means gone once it has let a peer in, not once that peer
// leaves. This session's later logins come in on the password the
// session remembers, so they are not affected.
password::update_temporary_password();
// Releases the budget `check_id_whitelist` charges against this address: only a peer
// that got this far proved more than a self-reported id.
self.clear_id_whitelist_failures();
@@ -2766,6 +2763,17 @@ impl Connection {
}
}
self.stream.set_send_timeout(
if self.file_transfer.is_some()
|| self.terminal
|| matches!(self.lr.union, Some(login_request::Union::PortForward(_)))
{
SEND_TIMEOUT_OTHER
} else {
SEND_TIMEOUT_VIDEO
},
);
if !crate::common::is_direct_ip_access(&lr.username) && lr.username != Config::get_id()
{
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)

View File

@@ -53,6 +53,82 @@ struct WaylandUinputRect {
struct WaylandLayout {
baseline: Vec<scrap::wayland::display::DisplayRect>,
live: Vec<scrap::wayland::display::DisplayRect>,
// What the live capturers were built against. Separate from `baseline` because a session
// init resets that one, and the generation detector needs a memory that a reset cannot
// erase: two inits straddling a rotation would otherwise leave nothing to compare against.
seen: Vec<scrap::wayland::display::DisplayRect>,
// A capturer recorded a build layout other than `seen`, tagged with the generation it was
// built at: the poll observed the live layout between that capturer's snapshot read and its
// record, so one of the two is stale and the next poll owes an edge whatever it sees. Only
// while that generation is current: the record can also land between the poll consuming an
// edge and the bump it promotes (or after the bump, with a snapshot from before it), and that
// capturer rebuilds on its own, so a second promotion would tear the fresh ones down again.
// Consumed by `observe`, which the poll runs right after `edge`; a session init's baseline
// reset leaves it alone.
unseen_build: Option<u64>,
}
#[cfg(target_os = "linux")]
impl WaylandLayout {
// Replace the per-session input baseline. Before the first poll the outgoing baseline is
// the only record of the layout the capturers were built against, so it seeds `seen`.
fn reset_baseline(&mut self, baseline: Vec<scrap::wayland::display::DisplayRect>) {
if self.seen.is_empty() {
let previous = std::mem::take(&mut self.baseline);
self.seen = previous;
}
self.baseline = baseline;
self.live.clear();
}
// An EDGE (live vs the layout the capturers were built against), not a level: comparing
// against the baseline latches true for the whole session. With nothing observed yet the
// baseline is that record, and a missing snapshot at init makes the first success the edge,
// or transform=0 sticks.
fn edge(
&self,
live: &[scrap::wayland::display::DisplayRect],
snapshot_missing: bool,
generation: u64,
) -> bool {
if self.unseen_build == Some(generation) {
return true;
}
if !self.seen.is_empty() {
return self.seen != live;
}
if self.baseline.is_empty() {
return snapshot_missing;
}
self.baseline != live
}
fn observe(&mut self, live: &[scrap::wayland::display::DisplayRect]) {
self.live = live.to_vec();
self.seen = live.to_vec();
self.unseen_build = None;
}
// What a capturer was built against, which seeds the memory when nothing else has. A session
// init whose wayland query failed leaves an EMPTY baseline, and the capturer's own retry can
// then succeed - so the capturer is the only thing that knows the layout it is showing, and
// without this a rotation before the first poll is invisible to `edge`. Only when empty: a
// capturer built later must not overwrite the memory the poll is keeping, since on a
// multi-display session that memory is what the OTHER capturers were built against. A build
// that disagrees with it is flagged instead: the capturer's snapshot read and this record
// are two steps, and a poll landing between them observes the live layout first, which
// would otherwise drop the record and leave the capturer on a transform nothing compares.
fn note_capturer(&mut self, built_on: &[scrap::wayland::display::DisplayRect], built_gen: u64) {
if built_on.is_empty() {
return;
}
if self.seen.is_empty() {
self.seen = built_on.to_vec();
} else if self.seen != built_on {
// The newest generation wins: a stale record landing late must not hide a fresh one.
self.unseen_build = Some(self.unseen_build.map_or(built_gen, |g| g.max(built_gen)));
}
}
}
// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic:
@@ -75,9 +151,24 @@ pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> {
#[cfg(target_os = "linux")]
pub(super) fn set_wayland_layout_baseline(baseline: Vec<scrap::wayland::display::DisplayRect>) {
WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed);
let mut lock = WAYLAND_LAYOUT.lock().unwrap();
lock.baseline = baseline;
lock.live.clear();
WAYLAND_LAYOUT.lock().unwrap().reset_baseline(baseline);
}
/// Record the layout a capturer was just built against, and the snapshot generation it read
/// before taking that layout. See `WaylandLayout::note_capturer`.
#[cfg(all(target_os = "linux", feature = "drm"))]
pub(super) fn note_capturer_layout(
displays: &[hbb_common::platform::linux::WaylandDisplayInfo],
built_gen: u64,
) {
if displays.is_empty() {
return;
}
let rects = scrap::wayland::display::logical_rects_of_displays(displays);
WAYLAND_LAYOUT
.lock()
.unwrap()
.note_capturer(&rects, built_gen);
}
// Remap an injected coordinate onto the live compositor layout when it has drifted from
@@ -100,11 +191,6 @@ fn refresh_wayland_uinput_rect_if_changed() {
if is_x11() || !crate::input_service::wayland_use_uinput() {
return;
}
// Nothing to poll at a login screen; the DRM path owns the rect there.
#[cfg(feature = "drm")]
if crate::platform::linux::is_login_screen_wayland_cached() {
return;
}
{
let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap();
if let Some(last_check) = lock.last_check {
@@ -120,14 +206,55 @@ fn refresh_wayland_uinput_rect_if_changed() {
// Refresh the per-display layout every poll: monitor origins can shift (e.g. two
// displays swap positions) without changing the overall desktop rect, and the mouse
// path needs the current per-display geometry to correct coordinates.
let drifted = {
let (live_changed, mut drifted) = {
let mut layout = WAYLAND_LAYOUT.lock().unwrap();
#[cfg(feature = "drm")]
let snapshot_missing = scrap::wayland::display::wayland_snapshot_missing();
#[cfg(not(feature = "drm"))]
let snapshot_missing = false;
#[cfg(feature = "drm")]
let generation = scrap::wayland::display::wayland_snapshot_generation();
#[cfg(not(feature = "drm"))]
let generation = 0;
let live_changed = layout.edge(&live_rects, snapshot_missing, generation);
let drifted = !layout.baseline.is_empty()
&& !live_rects.is_empty()
&& layout.baseline != live_rects;
layout.live = live_rects;
drifted
layout.observe(&live_rects);
(live_changed, drifted)
};
// Single owner of the generation bump: on the cache clear it let every session init tear
// down every other live capturer. Baseline promotes with the clear (rustdesk#15601).
#[cfg(feature = "drm")]
{
// An edge seen while DRM is transiently non-Available stays OWED rather than consumed.
static PROMOTION_OWED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
// The latch fires when a capturer was built with no wayland snapshot: a later cache
// refill makes wayland_snapshot_missing lie, so live_changed alone would miss it. Taken
// UNCONDITIONALLY: short-circuiting past it on a live_changed poll would leave it set and
// spend a second, spurious promotion one poll later on the freshly rebuilt capturer.
let blind_build = super::drm_capturer::take_unrotated_snapshot_pending();
if live_changed || blind_build {
PROMOTION_OWED.store(true, Ordering::Release);
}
if PROMOTION_OWED.load(Ordering::Acquire) && super::drm_capturer::is_available_cached() {
PROMOTION_OWED.store(false, Ordering::Release);
scrap::wayland::display::clear_wayland_displays_cache();
scrap::wayland::display::bump_layout_generation();
set_wayland_layout_baseline(live_rects.clone());
WAYLAND_LAYOUT.lock().unwrap().live = live_rects.clone();
drifted = false;
}
}
#[cfg(not(feature = "drm"))]
let _ = live_changed;
// At a login screen the DRM path owns the rect; only the range/remap update is skipped,
// the snapshot invalidation above must still run (a greeter session has no other trigger).
#[cfg(feature = "drm")]
if crate::platform::linux::is_login_screen_wayland_cached() {
return;
}
// The remap corrects for per-display origin shifts; the uinput ABS range corrects for
// the overall bounding box. Only enable the remap once the range matches the live
// layout, otherwise moves would be remapped into a range the device is not yet using.
@@ -721,3 +848,177 @@ mod tests {
assert_eq!(normalize_primary_display_idx(2, 2), 0);
}
}
#[cfg(all(test, target_os = "linux"))]
mod wayland_layout_tests {
use super::WaylandLayout;
use scrap::wayland::display::DisplayRect;
fn layout(w: i32, h: i32, transform: i32) -> Vec<DisplayRect> {
vec![DisplayRect {
name: "DP-1".into(),
x: 0,
y: 0,
w,
h,
transform,
}]
}
// rustdesk#15886: a video service starts, the output rotates, and a retry starts before the
// 1.5 s poll. The baseline is reset on both, so it cannot be the edge detector's memory.
#[test]
fn a_rotation_between_two_session_inits_is_still_an_edge() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(upright.clone());
l.observe(&upright);
l.reset_baseline(upright.clone());
l.reset_baseline(rotated.clone());
assert!(l.edge(&rotated, false, 0));
}
// The same, with no poll ever having run: the outgoing baseline is the only record of what
// the first capturer was built against.
#[test]
fn a_rotation_between_two_inits_before_the_first_poll_is_still_an_edge() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(upright.clone());
l.reset_baseline(rotated.clone());
assert!(l.edge(&rotated, false, 0));
}
// Control: without it the asserts above would pass on a detector that always fires.
#[test]
fn repeated_baseline_resets_without_a_rotation_are_not_an_edge() {
let upright = layout(1920, 1080, 0);
let mut l = WaylandLayout::default();
l.reset_baseline(upright.clone());
l.observe(&upright);
l.reset_baseline(upright.clone());
l.reset_baseline(upright.clone());
assert!(!l.edge(&upright, false, 0));
}
// rustdesk#15886: `ensure_inited()` runs the wayland query BEFORE the capturer exists, and a
// failure there saves an EMPTY baseline. The capturer's own retry can succeed a moment later
// and build on layout A, and that build is not blind, so nothing else records it. A rotation
// before the first poll then had no memory to be an edge against.
#[test]
fn a_capturer_built_after_a_failed_init_still_owes_a_rebuild() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(Vec::new());
l.note_capturer(&upright, 0);
assert!(l.edge(&rotated, false, 0));
// The same with another baseline reset between the build and the poll.
let mut l2 = WaylandLayout::default();
l2.reset_baseline(Vec::new());
l2.note_capturer(&upright, 0);
l2.reset_baseline(rotated.clone());
assert!(l2.edge(&rotated, false, 0));
// Control: no rotation, no edge, in both shapes.
let mut l3 = WaylandLayout::default();
l3.reset_baseline(Vec::new());
l3.note_capturer(&upright, 0);
assert!(!l3.edge(&upright, false, 0));
}
// A capturer built while the poll already has a memory must not overwrite it.
#[test]
fn a_later_capturer_does_not_overwrite_the_polls_memory() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.observe(&upright);
l.note_capturer(&rotated, 0);
assert!(l.edge(&rotated, false, 0), "the poll's memory still says upright");
}
// The constructor's snapshot read and its `note_capturer` are two steps, and the poll can
// land between them. After a failed init (empty baseline) the constructor takes A and
// publishes it; the output rotates; the poll reads B live, finds nothing recorded and the
// snapshot present, so no edge, and observes B. The late `note_capturer(A)` then met a
// non-empty memory and was dropped: the capturer showed A while the detector held B, and B
// against B never bumped the generation.
#[test]
fn a_capturer_record_that_lost_the_race_with_the_first_poll_is_still_an_edge() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(Vec::new());
assert!(!l.edge(&rotated, false, 0), "nothing recorded and the snapshot is present");
l.observe(&rotated);
l.note_capturer(&upright, 0);
assert!(l.edge(&rotated, false, 0), "the capturer is built on upright, live is rotated");
// The promotion consumes it: the next poll sees the same layout and stays quiet.
l.observe(&rotated);
l.reset_baseline(rotated.clone());
assert!(!l.edge(&rotated, false, 0));
// The same with a session init between the late record and the poll.
let mut l2 = WaylandLayout::default();
l2.reset_baseline(Vec::new());
l2.observe(&rotated);
l2.note_capturer(&upright, 0);
l2.reset_baseline(rotated.clone());
assert!(l2.edge(&rotated, false, 0));
// Control: a late record that agrees with the poll's memory is not an edge.
let mut l3 = WaylandLayout::default();
l3.reset_baseline(Vec::new());
l3.observe(&upright);
l3.note_capturer(&upright, 0);
assert!(!l3.edge(&upright, false, 0));
}
// The late record can also land after the poll consumed the edge but before the bump that
// edge promotes, or after the bump with a snapshot taken before it. That capturer is stale
// by generation and rebuilds on its own, so its record must not buy a second promotion
// that tears the freshly rebuilt capturers down again.
#[test]
fn a_late_record_from_a_generation_already_promoted_is_not_a_second_edge() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(upright.clone());
l.observe(&upright);
// The output rotates, the poll consumes the edge, the capturer built on upright at
// generation 7 records late, and the poll promotes to 8.
assert!(l.edge(&rotated, false, 7));
l.observe(&rotated);
l.note_capturer(&upright, 7);
l.reset_baseline(rotated.clone());
assert!(!l.edge(&rotated, false, 8), "the capturer built at 7 rebuilds on its own");
// Control: a disagreeing record AT the promoted generation is a real edge.
l.observe(&rotated);
l.note_capturer(&upright, 8);
assert!(l.edge(&rotated, false, 8));
// A stale record landing after a fresh one must not hide the fresh one.
l.observe(&rotated);
l.note_capturer(&upright, 8);
l.note_capturer(&upright, 7);
assert!(l.edge(&rotated, false, 8));
}
// A promotion consumes the edge: the next poll sees the same layout and must stay quiet.
#[test]
fn a_promoted_layout_is_not_an_edge_again() {
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(layout(1920, 1080, 0));
l.observe(&rotated);
l.reset_baseline(rotated.clone());
assert!(!l.edge(&rotated, false, 0));
}
}

View File

@@ -52,9 +52,17 @@ impl FrameSlot {
}
}
/// `Shared.transform` before new() stores the real value: a cursor arriving this early is held
/// back and replayed once the session transform is in, because the producer will not resend it
/// until the shape changes.
const TRANSFORM_PENDING: i32 = i32::MIN;
struct Shared {
slot: Mutex<FrameSlot>,
cv: Condvar,
// Session transform, TRANSFORM_PENDING until new() stores it post-handshake; the receive
// thread turns cursor bitmaps with it and defers any cursor that races the store.
transform: std::sync::atomic::AtomicI32,
}
pub struct IpcDrmCapturer {
@@ -63,7 +71,14 @@ pub struct IpcDrmCapturer {
display: i32,
connector: Option<String>,
// What the encoder was sized from: CapturerInfo{width,height} is read once, at build time.
// With a rotated output these are the ROTATED dimensions, matching the frames delivered.
session_size: Option<(usize, usize)>,
// Output rotation in degrees: a rotated scanout holds the desktop drawn sideways, so frames
// are turned back before delivery. Fixed per session; a rotation rebuilds the capturer.
transform: i32,
// The wayland snapshot generation this session was built from: a later invalidation means
// the layout (a rotation included) may have changed, and frame() asks for a rebuild.
snapshot_gen: u64,
cur: Vec<u8>,
cur_w: usize,
cur_h: usize,
@@ -76,6 +91,102 @@ fn connector_key(d: &DrmDisplayInfo) -> String {
format!("{}:{}", d.device, d.name)
}
/// Frame dimensions after undoing `transform` degrees of output rotation.
fn rotated_dims(transform: i32, w: usize, h: usize) -> (usize, usize) {
if transform == 90 || transform == 270 {
(h, w)
} else {
(w, h)
}
}
/// Hotspot of a rotated cursor bitmap: the same point mapping `unrotate_bgra` applies to
/// pixels, applied to the one coordinate that must keep naming the click point.
fn unrotate_hotspot(transform: i32, w: i32, h: i32, hotx: i32, hoty: i32) -> (i32, i32) {
match transform {
90 => (h - 1 - hoty, hotx),
180 => (w - 1 - hotx, h - 1 - hoty),
270 => (hoty, w - 1 - hotx),
_ => (hotx, hoty),
}
}
/// Turn a 4-byte-pixel frame upright into tightly packed `dst`, undoing `transform` degrees;
/// padded `src` rows ok (stride = len/h). Direction pinned by the tests to the measured anchor
/// of rustdesk#15886; libyuv walks pixels, so channel order does not matter.
fn unrotate_bgra(src: &[u8], w: usize, h: usize, transform: i32, dst: &mut Vec<u8>) {
const PX: usize = 4;
let stride = if h > 0 { src.len() / h } else { 0 };
let (dw, dh) = rotated_dims(transform, w, h);
dst.resize(
dw.checked_mul(dh).and_then(|p| p.checked_mul(PX)).unwrap_or(0),
0,
);
if dst.is_empty() || stride < w * PX {
log::error!("unrotate: rejected geometry {w}x{h} stride {stride}; frame left blank");
return;
}
let mode = match transform {
90 => scrap::RotationMode::kRotate90,
180 => scrap::RotationMode::kRotate180,
270 => scrap::RotationMode::kRotate270,
_ => scrap::RotationMode::kRotate0,
};
unsafe {
scrap::ARGBRotate(
src.as_ptr(),
stride as i32,
dst.as_mut_ptr(),
(dw * PX) as i32,
w as i32,
h as i32,
mode,
);
}
}
/// Transform and augmented origin for one wire entry, derived from ONE wayland snapshot so both
/// reflect the same output assignment; two `get_displays()` reads could straddle a cache
/// invalidation. `None` origin means nothing to augment with (caller keeps the DRM origin).
fn transform_and_origin(
drm: &[DrmDisplayInfo],
wire_idx: usize,
wl: &scrap::wayland::display::Displays,
) -> (i32, Option<(i32, i32)>) {
if wl.displays.is_empty() || (wl.displays.len() == 1 && drm.len() > 1) {
if wl.displays.is_empty() && !drm.is_empty() {
// A later successful enumeration refills the cache and hides this state from
// wayland_snapshot_missing, so the layout poll needs this durable record to know a
// capturer was built blind and owes a rebuild.
UNROTATED_SNAPSHOT_PENDING.store(true, Ordering::Release);
log::warn!(
"drm: no wayland snapshot at capturer build for display {:?}; assuming unrotated",
drm.get(wire_idx).map(|d| d.name.as_str()).unwrap_or("?")
);
}
return (0, None);
}
let assignment = assign_wayland_outputs(drm, &wl.displays);
// The transform comes ONLY from an identity match (name, or unique resolution), through the
// SAME progressive-taken pass the advertise side keys its swap off: the layout-order
// fallback is fine for an origin guess, but a rotation pinned on a guess splits the
// advertised dimensions from the delivered ones.
let transform = identity_matches(drm, &wl.displays)
.get(wire_idx)
.copied()
.flatten()
.map(|j| wl.displays[j].transform)
// Hardware-rotated 180 scans out already upright (i915 advertises rotate-180 and
// mutter uses it), and wl_output cannot tell hardware from software rotation, so 180
// keeps master behavior until the plane rotation property travels the wire.
.map(|t| if t == 90 || t == 270 { t } else { 0 })
.unwrap_or(0);
let origin = augment_with_wayland_geometry_from(drm, wl, &assignment)
.get(wire_idx)
.map(|di| (di.x, di.y));
(transform, origin)
}
/// Takes DRM_STATE: never call it while holding one of the per-display maps below.
fn display_info_of(display: i32) -> Option<DrmDisplayInfo> {
match &*DRM_STATE.lock().unwrap() {
@@ -96,6 +207,9 @@ struct DisplayHealth {
/// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node
/// is not the GPU that exported the scanout. Follows the monitor for the process run.
prefer_cpu: bool,
/// The PipeWire fallback for this display was rejected on geometry (a transposed stream), so
/// the lone-display carve-out in `mark_demoted_displays` must not keep advertising it online.
fallback_rejected: bool,
}
impl DisplayHealth {
@@ -107,6 +221,7 @@ impl DisplayHealth {
last_build: None,
rapid_builds: 0,
prefer_cpu: false,
fallback_rejected: false,
}
}
@@ -185,6 +300,14 @@ fn render_node_count() -> usize {
}
static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// A capturer was built with no wayland snapshot and runs unrotated; the layout poll consumes
/// this to bump the generation once a live snapshot exists.
static UNROTATED_SNAPSHOT_PENDING: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub(super) fn take_unrotated_snapshot_pending() -> bool {
UNROTATED_SNAPSHOT_PENDING.swap(false, std::sync::atomic::Ordering::AcqRel)
}
static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
impl IpcDrmCapturer {
@@ -193,7 +316,7 @@ impl IpcDrmCapturer {
pub fn new(
display: i32,
expected: Option<DrmDisplayInfo>,
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize)> {
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize, Option<(i32, i32)>)> {
let shared = Arc::new(Shared {
slot: Mutex::new(FrameSlot {
latest: None,
@@ -201,6 +324,7 @@ impl IpcDrmCapturer {
ended: None,
}),
cv: Condvar::new(),
transform: std::sync::atomic::AtomicI32::new(TRANSFORM_PENDING),
});
let stop = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel::<ResultType<(Vec<DrmDisplayInfo>, usize)>>();
@@ -220,6 +344,18 @@ impl IpcDrmCapturer {
bail!("drm capture handshake timed out");
}
};
// One snapshot for the session: transform, origin and the advertised swap must all
// reflect the same output assignment. The generation is read BEFORE the snapshot, so a
// clear racing the build rebuilds once instead of running a session on stale geometry.
let snapshot_gen = scrap::wayland::display::wayland_snapshot_generation();
let wl = scrap::wayland::display::get_displays();
let (transform, origin) = transform_and_origin(&displays, wire_idx, &wl);
// This capturer now shows that layout. If the session init's own wayland query failed it
// saved an empty baseline, so this is the only record of what the stream is built on.
super::display_service::note_capturer_layout(&wl.displays, snapshot_gen);
shared
.transform
.store(transform, std::sync::atomic::Ordering::Release);
Ok((
IpcDrmCapturer {
shared,
@@ -228,7 +364,9 @@ impl IpcDrmCapturer {
connector: displays.get(wire_idx).map(connector_key),
session_size: displays
.get(wire_idx)
.map(|d| (d.width as usize, d.height as usize)),
.map(|d| rotated_dims(transform, d.width as usize, d.height as usize)),
transform,
snapshot_gen,
cur: Vec::new(),
cur_w: 0,
cur_h: 0,
@@ -237,6 +375,7 @@ impl IpcDrmCapturer {
},
displays,
wire_idx,
origin,
))
}
@@ -294,10 +433,21 @@ impl TraitCapturer for IpcDrmCapturer {
}
if let Some((w, h, fmt, buf)) = slot.latest.take() {
drop(slot);
// convert_to_yuv only refuses a source LARGER than its destination, so a smaller
// frame leaves stale edges on screen. On the FIRST frame nothing changed: the list
// carries the CRTC mode, a frame the scanout fb, different when a CRTC scales.
if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) {
// A layout change bumps the generation and is otherwise invisible here (mode
// and framebuffer keep their size). Rebuild for the new transform; not counted
// against health: the layout moved, the display did not fail.
if scrap::wayland::display::wayland_snapshot_generation() != self.snapshot_gen {
self.shared.slot.lock().unwrap().recycle(buf);
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drm: display {} layout changed; rebuilding", self.display),
));
}
// Frames arrive in scanout orientation, the session was sized rotated, so the
// guard compares rotated dims. convert_to_yuv only refuses a LARGER source (a
// smaller one leaves stale edges); first frame: CRTC mode vs scanout fb.
let (fw, fh) = rotated_dims(self.transform, w, h);
if self.session_size.is_some_and(|(sw, sh)| (fw, fh) != (sw, sh)) {
self.shared.slot.lock().unwrap().recycle(buf);
if !self.got_frame {
self.note_session_without_frame();
@@ -311,15 +461,35 @@ impl TraitCapturer for IpcDrmCapturer {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding",
"drm: display {} {what} ({sw}x{sh} -> {fw}x{fh}); rebuilding",
self.display
),
));
}
let previous = std::mem::replace(&mut self.cur, buf);
self.shared.slot.lock().unwrap().recycle(previous);
self.cur_w = w;
self.cur_h = h;
if self.transform == 0 {
let previous = std::mem::replace(&mut self.cur, buf);
self.shared.slot.lock().unwrap().recycle(previous);
} else if !matches!(fmt, Pixfmt::BGRA | Pixfmt::RGBA) {
// Unreachable with today's producers (the convert path emits 4-byte pixels
// and the CPU path hardcodes BGRA); kept so a future non-4-byte producer
// fails the session instead of shearing the image.
self.shared.slot.lock().unwrap().recycle(buf);
if !self.got_frame {
self.note_session_without_frame();
}
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"drm: display {} delivered {fmt:?} on a rotated output; rebuilding",
self.display
),
));
} else {
unrotate_bgra(&buf, w, h, self.transform, &mut self.cur);
self.shared.slot.lock().unwrap().recycle(buf);
}
self.cur_w = fw;
self.cur_h = fh;
self.cur_fmt = fmt;
if !self.got_frame {
// Clear ONLY the streak: `rapid_builds` is for a display that delivers a first
@@ -330,6 +500,7 @@ impl TraitCapturer for IpcDrmCapturer {
h.zero_frame_streak = 0;
h.demotes = 0;
h.since = Instant::now();
h.fallback_rejected = false;
}
}
}
@@ -460,10 +631,21 @@ async fn recv_thread(
}
let _ = tx.send(Ok((displays, wire_idx)));
// A cursor that arrived before new() stored the session transform, held for replay. Only the
// newest matters; the 200 ms recv timeout guarantees this is retried even on an idle wire.
let mut pending_cursor: Option<(u64, u32, u32, i32, i32, Vec<u8>)> = None;
let end_reason = loop {
if stop.load(Ordering::SeqCst) {
break "stopped".to_owned();
}
if pending_cursor.is_some() {
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
if t != TRANSFORM_PENDING {
if let Some((id, width, height, hotx, hoty, raw)) = pending_cursor.take() {
deliver_drm_cursor(display, cursor_epoch, id, width, height, hotx, hoty, raw, t);
}
}
}
let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await {
None => continue, // timeout: re-check stop at the loop top
Some(Ok(pair)) => pair,
@@ -580,18 +762,23 @@ async fn recv_thread(
raw.len()
);
}
set_drm_cursor(
display,
cursor_epoch,
DrmCursorData {
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
if t == TRANSFORM_PENDING {
pending_cursor = Some((id, width, height, hotx, hoty, raw));
} else {
pending_cursor = None;
deliver_drm_cursor(
display,
cursor_epoch,
id,
width: width as i32,
height: height as i32,
width,
height,
hotx,
hoty,
colors: raw,
},
);
raw,
t,
);
}
}
Ok(Err(err)) => break format!("cursor body: {err}"),
}
@@ -717,6 +904,56 @@ fn remove_drm_cursor(display: i32, epoch: u64) {
}
}
/// Unrotate a wire cursor into the session orientation and publish it. The compositor
/// pre-rotates the bitmap it programs into the cursor plane, so over the unrotated video the
/// cursor alone would stay turned and its hotspot transposed (review finding 11 on
/// rustdesk#15889). The wire id hashes only the plane pixels and geometry, so a stream rebuilt
/// under a new transform resends the SAME id and the client's by-id cursor cache would keep the
/// old orientation: fold the transform in (the producer's own FNV step) so id and orientation
/// can never disagree. The hidden sentinel must survive untouched.
#[allow(clippy::too_many_arguments)]
fn deliver_drm_cursor(
display: i32,
cursor_epoch: u64,
id: u64,
width: u32,
height: u32,
hotx: i32,
hoty: i32,
raw: Vec<u8>,
t: i32,
) {
let (width, height, hotx, hoty, colors) = if t == 90 || t == 270 {
let mut turned = Vec::new();
unrotate_bgra(&raw, width as usize, height as usize, t, &mut turned);
let (hx, hy) = unrotate_hotspot(t, width as i32, height as i32, hotx, hoty);
(height as i32, width as i32, hx, hy, turned)
} else {
(width as i32, height as i32, hotx, hoty, raw)
};
let id = fold_cursor_id(id, t);
set_drm_cursor(
display,
cursor_epoch,
DrmCursorData {
id,
width,
height,
hotx,
hoty,
colors,
},
);
}
fn fold_cursor_id(id: u64, t: i32) -> u64 {
if id == scrap::drm_reader::HIDDEN_CURSOR_ID {
id
} else {
(id ^ t as u32 as u64).wrapping_mul(1099511628211)
}
}
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
let map = DRM_CURSOR.lock().unwrap();
map.values()
@@ -1183,12 +1420,22 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> {
}
// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it
// offline; a single connector remains usable through the whole-desktop fallback.
// offline; a single connector remains usable through the whole-desktop fallback - unless that
// fallback itself was rejected on geometry, in which case advertising the lone display online
// would restart-loop the video service against a stream nothing can serve.
fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
if list.len() <= 1 {
if let (Some(display), Some(info)) = (list.first(), infos.first_mut()) {
if health
.get(&connector_key(display))
.is_some_and(|health| health.demoted() && health.fallback_rejected)
{
info.online = false;
}
}
return;
}
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
for (display, info) in list.iter().zip(infos.iter_mut()) {
if health
.get(&connector_key(display))
@@ -1199,6 +1446,21 @@ fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
}
}
/// The PipeWire fallback for this display was rejected on geometry; recorded so the lone-display
/// carve-out above stops advertising a display nothing can serve. Cleared by a delivered frame
/// and by the demote-cooldown re-arm.
pub(super) fn mark_fallback_rejected(display_idx: usize) {
let Some(expected) = display_info_of(display_idx as i32) else {
return;
};
DRM_DISPLAY_HEALTH
.lock()
.unwrap()
.entry(connector_key(&expected))
.or_insert_with(DisplayHealth::new)
.fallback_rejected = true;
}
fn primary_index_from_assignment(assignment: &[Option<usize>], primary: usize) -> usize {
assignment
.iter()
@@ -1266,18 +1528,36 @@ fn augment_with_wayland_geometry_from(
if origin_only && drm.len() > 1 {
return infos;
}
let identity = identity_matches(drm, &wl.displays);
for (i, info) in infos.iter_mut().enumerate() {
let Some(w) = matched[i].map(|j| &wl.displays[j]) else {
continue;
};
info.x = w.x;
info.y = w.y;
// Rotated size before the origin-only cut: a lone rotated output still delivers rotated
// frames, so it must advertise them; only the logical-scale adoption stays multi-output.
// original_resolution follows in the same motion, or the client reads the transposed
// current size against an untransposed original as a third-party resolution change.
// Identity matches ONLY, the same rule the capturer's transform follows: swapping on a
// layout-order guess advertises dimensions the capturer will not deliver.
let is_identity = identity[i].is_some() && identity[i] == matched[i];
if is_identity && (w.transform == 90 || w.transform == 270) {
std::mem::swap(&mut info.width, &mut info.height);
info.original_resolution = super::display_service::get_original_resolution(
&drm[i].name,
info.width as usize,
info.height as usize,
);
}
if origin_only {
continue;
}
if let Some((lw, lh)) = w.logical_size {
if lw > 0 && lh > 0 {
info.scale = drm[i].width as f64 / lw as f64;
// Post-swap width over logical width, which arrives already swapped when rotated:
// the unrotated numerator made a rotated 1:1 monitor advertise scale 16/9.
info.scale = info.width as f64 / lw as f64;
info.original_resolution = super::display_service::get_original_resolution(
&drm[i].name,
lw as usize,
@@ -1292,18 +1572,62 @@ fn augment_with_wayland_geometry_from(
/// Each output goes to at most one connector; unmatched ones take the next free output of the same
/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at
/// DRM's (0,0).
fn assign_wayland_outputs(
/// The identity half of the assignment (name, or unique resolution), same progressive `taken`
/// as the full one. Rotation keys off THIS on both sides: swapping or turning on a layout-order
/// guess splits the advertised dimensions from the delivered frames.
/// Identity assignment in two GLOBAL passes: every exact name match is reserved first, then
/// resolution pairing runs on the unmatched remainder, and only when it is forced - exactly one
/// free output AND exactly one unmatched connector at that resolution. A resolution guess for an
/// earlier connector must never steal an exact name match from a later one.
fn identity_matches(
drm: &[DrmDisplayInfo],
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
) -> Vec<Option<usize>> {
let mut taken = vec![false; wl.len()];
let mut matched: Vec<Option<usize>> = vec![None; drm.len()];
for (i, d) in drm.iter().enumerate() {
if let Some(j) = match_wayland_display(d, wl, &taken) {
let dn = normalize_connector(&d.name);
if let Some((j, _)) = wl
.iter()
.enumerate()
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
{
matched[i] = Some(j);
taken[j] = true;
}
}
for (i, d) in drm.iter().enumerate() {
if matched[i].is_some() {
continue;
}
let free_same: Vec<usize> = wl
.iter()
.enumerate()
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
.map(|(j, _)| j)
.collect();
let unmatched_same = drm
.iter()
.enumerate()
.filter(|(k, o)| matched[*k].is_none() && o.width == d.width && o.height == d.height)
.count();
if free_same.len() == 1 && unmatched_same == 1 {
matched[i] = Some(free_same[0]);
taken[free_same[0]] = true;
}
}
matched
}
fn assign_wayland_outputs(
drm: &[DrmDisplayInfo],
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
) -> Vec<Option<usize>> {
let mut matched = identity_matches(drm, wl);
let mut taken = vec![false; wl.len()];
for m in matched.iter().flatten() {
taken[*m] = true;
}
for (i, d) in drm.iter().enumerate() {
if matched[i].is_some() {
continue;
@@ -1329,30 +1653,6 @@ fn assign_wayland_outputs(
matched
}
fn match_wayland_display(
d: &DrmDisplayInfo,
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
taken: &[bool],
) -> Option<usize> {
let dn = normalize_connector(&d.name);
if let Some((j, _)) = wl
.iter()
.enumerate()
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
{
return Some(j);
}
let same_res: Vec<usize> = wl
.iter()
.enumerate()
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
.map(|(j, _)| j)
.collect();
if same_res.len() == 1 {
return Some(same_res[0]);
}
None
}
/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1").
/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2".
@@ -1413,11 +1713,13 @@ pub(super) fn get_capturer_info(
}
h.zero_frame_streak = 0;
h.since = Instant::now();
// The cooldown re-arms DRM for this display, so the fallback verdict restarts too.
h.fallback_rejected = false;
}
}
}
// Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below.
let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?;
let (capturer, displays, wire_idx, origin) = IpcDrmCapturer::new(display_idx as i32, expected)?;
// The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window.
if let Some(key) = key.clone() {
let now = Instant::now();
@@ -1445,16 +1747,14 @@ pub(super) fn get_capturer_info(
.get(wire_idx)
.ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))?
.clone();
// Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin
// matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer.
let origin = augment_with_wayland_geometry(&displays)
.get(wire_idx)
.map(|di| (di.x, di.y))
.unwrap_or((d.x, d.y));
// Origin and transform come from the ONE snapshot new() resolved, so both reflect the
// same output assignment; dimensions stay PHYSICAL, rotated to frame orientation.
let origin = origin.unwrap_or((d.x, d.y));
let (cap_w, cap_h) = rotated_dims(capturer.transform, d.width as usize, d.height as usize);
Ok(super::video_service::CapturerInfo {
origin,
width: d.width as usize,
height: d.height as usize,
width: cap_w,
height: cap_h,
ndisplay,
current: display_idx,
privacy_mode_id: 0,
@@ -1482,11 +1782,14 @@ mod drm_capturer_tests {
ended: None,
}),
cv: Condvar::new(),
transform: std::sync::atomic::AtomicI32::new(0),
}),
stop: Arc::new(AtomicBool::new(false)),
display: 0,
connector,
session_size: session,
transform: 0,
snapshot_gen: scrap::wayland::display::wayland_snapshot_generation(),
cur: Vec::new(),
cur_w: 0,
cur_h: 0,
@@ -1495,6 +1798,172 @@ mod drm_capturer_tests {
}
}
/// One BGRA pixel per label byte, so a rotation result reads as a matrix of labels.
fn px_frame(labels: &[&[u8]], pad_bytes: usize) -> (Vec<u8>, usize, usize) {
let h = labels.len();
let w = labels[0].len();
let mut buf = Vec::new();
for row in labels {
for &l in *row {
buf.extend_from_slice(&[l, l, l, 255]);
}
buf.extend(std::iter::repeat(0u8).take(pad_bytes));
}
(buf, w, h)
}
fn labels_of(buf: &[u8], w: usize, h: usize) -> Vec<Vec<u8>> {
(0..h)
.map(|y| (0..w).map(|x| buf[(y * w + x) * 4]).collect())
.collect()
}
#[test]
fn a_lone_display_goes_offline_only_when_its_fallback_was_rejected() {
// Unique name = unique health key; DRM_DISPLAY_HEALTH is process-wide.
let list = vec![drm_display("TEST-lone-fallback", 1080, 1920)];
let key = connector_key(&list[0]);
let demoted = DisplayHealth {
zero_frame_streak: DRM_GRAB_MAX_FAILURES,
demotes: 1,
..DisplayHealth::new()
};
// Demoted alone keeps the lone display online: the whole-desktop fallback is usable.
DRM_DISPLAY_HEALTH.lock().unwrap().insert(key.clone(), demoted);
let mut infos = vec![DisplayInfo {
online: true,
..Default::default()
}];
mark_demoted_displays(&list, &mut infos);
assert!(infos[0].online, "the lone-display carve-out must survive");
// A rejected fallback ends the carve-out: advertising online would restart-loop.
DRM_DISPLAY_HEALTH
.lock()
.unwrap()
.get_mut(&key)
.expect("just inserted")
.fallback_rejected = true;
mark_demoted_displays(&list, &mut infos);
assert!(!infos[0].online, "a rejected fallback must take the lone display offline");
// Once the demotion cooldown lapses the display is no longer demoted, and online returns
// even with the rejection still latched (the re-arm will clear it on the next build).
DRM_DISPLAY_HEALTH
.lock()
.unwrap()
.get_mut(&key)
.expect("still there")
.since = Instant::now() - demote_cooldown(1) - Duration::from_secs(1);
infos[0].online = true;
mark_demoted_displays(&list, &mut infos);
assert!(infos[0].online, "past the cooldown the verdict is DRM's to retry");
}
#[test]
fn the_cursor_id_names_the_orientation_too() {
// Same wire cursor under two transforms must publish as two ids, or the client's by-id
// cache serves the previous orientation after a mid-session rotation.
let wire = 0xDEAD_BEEF_u64;
assert_ne!(fold_cursor_id(wire, 0), fold_cursor_id(wire, 90));
assert_ne!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 270));
// Deterministic per (id, transform), so an unchanged cursor is still deduped.
assert_eq!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 90));
// The hidden sentinel is compared by VALUE at the consumers, so it must pass unfolded.
let hidden = scrap::drm_reader::HIDDEN_CURSOR_ID;
assert_eq!(fold_cursor_id(hidden, 90), hidden);
}
#[test]
fn unrotate_hotspot_follows_the_pixel_mapping() {
// 3 wide x 2 tall, hotspot at (2,0) (top-right): after the 90 turn (left column to top
// row) that pixel sits at (1,2) in the 2x3 result; 270 sends it to (0,0).
assert_eq!(unrotate_hotspot(90, 3, 2, 2, 0), (1, 2));
assert_eq!(unrotate_hotspot(270, 3, 2, 2, 0), (0, 0));
assert_eq!(unrotate_hotspot(180, 3, 2, 2, 0), (0, 1));
assert_eq!(unrotate_hotspot(0, 3, 2, 2, 0), (2, 0));
}
#[test]
fn a_stale_snapshot_generation_asks_for_a_rebuild_without_blaming_the_display() {
let mut c = capturer_named(Some((64, 32)), Some("test:gen-rebuild"));
c.snapshot_gen = c.snapshot_gen.wrapping_sub(1);
put_frame(&c, 64, 32);
let err = match c.frame(Duration::from_millis(50)) {
Err(e) => e,
Ok(_) => panic!("a stale generation must rebuild, not deliver"),
};
assert!(err.to_string().contains("layout changed"), "{err}");
assert!(!c.got_frame);
assert_eq!(
zero_frame_streak_of(&c),
0,
"a layout rebuild must not count against display health"
);
}
#[test]
fn unrotate_90_maps_the_left_column_to_the_top_row() {
// The measured anchor from rustdesk#15886: mutter transform=1 carries the panel bar down
// the scanout's LEFT edge, and upright means that edge becomes the TOP row.
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
let mut dst = Vec::new();
unrotate_bgra(&src, w, h, 90, &mut dst);
// src left column top-to-bottom = [1, 4]; clockwise puts it on the top row as [4, 1].
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
}
#[test]
fn unrotate_270_is_the_inverse_of_90() {
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
let mut once = Vec::new();
unrotate_bgra(&src, w, h, 90, &mut once);
let mut back = Vec::new();
unrotate_bgra(&once, h, w, 270, &mut back);
assert_eq!(back, src);
}
#[test]
fn unrotate_180_reverses_both_axes() {
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
let mut dst = Vec::new();
unrotate_bgra(&src, w, h, 180, &mut dst);
assert_eq!(labels_of(&dst, w, h), vec![vec![6, 5, 4], vec![3, 2, 1]]);
}
#[test]
fn unrotate_reads_padded_strides_and_writes_tight() {
// Row stride is derived from len/h, so a padded source must not shear the result.
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 8);
let mut dst = Vec::new();
unrotate_bgra(&src, w, h, 90, &mut dst);
assert_eq!(dst.len(), w * h * 4);
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
let mut plain = Vec::new();
unrotate_bgra(&src, w, h, 0, &mut plain);
assert_eq!(labels_of(&plain, w, h), vec![vec![1, 2, 3], vec![4, 5, 6]]);
}
#[test]
fn a_rotated_session_delivers_rotated_frames_and_guards_in_rotated_dims() {
use scrap::TraitPixelBuffer;
let mut c = capturer_with(Some((32, 64))); // rotated session of a 64x32 scanout
c.transform = 90;
put_frame(&c, 64, 32);
match c.frame(Duration::from_millis(50)) {
Ok(Frame::PixelBuffer(pb)) => {
assert_eq!((pb.width(), pb.height()), (32, 64));
}
Ok(_) => panic!("expected a pixel-buffer frame"),
Err(err) => panic!("expected a delivered frame, got {err}"),
}
// A scanout change still ends the session, reported in rotated dimensions.
put_frame(&c, 32, 64);
let err = match c.frame(Duration::from_millis(50)) {
Err(e) => e,
Ok(_) => panic!("a scanout change must end a rotated session too"),
};
assert!(err.to_string().contains("(32x64 -> 64x32)"), "{err}");
}
fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 {
let key = c.connector.clone().expect("this check needs an identity");
DRM_DISPLAY_HEALTH
@@ -1525,6 +1994,7 @@ mod drm_capturer_tests {
h.rapid_builds = 3;
h.last_build = Some(Instant::now());
h.prefer_cpu = true;
h.fallback_rejected = true;
}
put_frame(&c, 64, 32);
assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_)));
@@ -1537,6 +2007,10 @@ mod drm_capturer_tests {
};
assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak");
assert_eq!(h.demotes, 0, "and the demotion count that streak drove");
assert!(
!h.fallback_rejected,
"a delivered frame also refutes the rejected-fallback verdict"
);
assert_eq!(
h.rapid_builds, 3,
"but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \
@@ -1642,9 +2116,53 @@ mod drm_capturer_tests {
height: h,
logical_size: Some((w, h)),
refresh_rate: 60,
transform: 0,
}
}
#[test]
fn a_lone_rotated_output_advertises_delivered_dimensions() {
// Fix for the origin-only cut: one connector, one rotated output. The capturer will
// deliver rotated frames, so the advertised size must swap even in the origin-only case,
// while the logical scale is still not adopted (stays 1.0).
let drm = [drm_display("HDMI-A-1", 1920, 1080)];
let mut out = wl_display("HDMI-1", 0, 0, 1920, 1080);
out.transform = 90;
let wl = scrap::wayland::display::Displays {
primary: 0,
displays: vec![out],
};
let assignment = assign_wayland_outputs(&drm, &wl.displays);
let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment);
assert_eq!((infos[0].width, infos[0].height), (1080, 1920));
assert_eq!(infos[0].scale, 1.0);
}
#[test]
fn transform_and_origin_come_from_the_same_snapshot() {
// Both derive from ONE Displays snapshot: the rotated output's transform and its origin
// must belong to the same assignment, and the multi-connector one-output guard zeroes
// both rather than mixing a guessed origin with a real transform.
let drm = [
drm_display("HDMI-A-1", 1920, 1080),
drm_display("DP-1", 2560, 1440),
];
let mut rotated = wl_display("DP-1", 1920, 0, 2560, 1440);
rotated.transform = 270;
let wl = scrap::wayland::display::Displays {
primary: 0,
displays: vec![rotated, wl_display("HDMI-1", 0, 0, 1920, 1080)],
};
let (t, origin) = transform_and_origin(&drm, 1, &wl);
assert_eq!(t, 270);
assert_eq!(origin, Some((1920, 0)));
let lone = scrap::wayland::display::Displays {
primary: 0,
displays: vec![wl_display("HDMI-1", 0, 0, 1920, 1080)],
};
assert_eq!(transform_and_origin(&drm, 1, &lone), (0, None));
}
#[test]
fn one_connector_assignment_drives_geometry_and_primary() {
let drm = [
@@ -1725,6 +2243,32 @@ mod drm_capturer_tests {
);
}
#[test]
fn a_resolution_guess_never_steals_an_exact_name_match() {
// The review's scenario: an earlier connector with an unmatchable name shares the
// resolution of a later connector's exact name match. Names reserve globally first.
let drm = vec![
drm_display("DSI-1", 1920, 1080),
drm_display("HDMI-A-1", 1920, 1080),
];
let wl = vec![
wl_display("HDMI-1", 0, 0, 1920, 1080),
wl_display("Unknown-9", 1920, 0, 2560, 1440),
];
let m = identity_matches(&drm, &wl);
assert_eq!(m[1], Some(0), "the exact name match must win globally");
assert_eq!(m[0], None, "the leftover pairing is not forced, so no identity");
// Two unmatched connectors at the lone free resolution: ambiguous on the DRM side too,
// so rotation must not be pinned on either.
let drm2 = vec![
drm_display("DSI-1", 1920, 1080),
drm_display("DSI-2", 1920, 1080),
];
let wl2 = vec![wl_display("HDMI-1", 0, 0, 1920, 1080)];
let m2 = identity_matches(&drm2, &wl2);
assert!(m2[0].is_none() && m2[1].is_none());
}
#[test]
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];

View File

@@ -108,7 +108,8 @@ struct CapDisplayInfo {
}
/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be
/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so
/// asked. `(minx, maxx, miny, maxy)`, in delivered-orientation physical pixels (a rotated
/// output counts transposed, matching its frames): no compositor here applied a scale, so
/// unlike `desktop_rect_of` there is no logical size to handle.
#[cfg(feature = "drm")]
fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
@@ -521,11 +522,13 @@ pub(super) fn get_capturer_for_display(
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
// `try_fix_logical_size` only repairs the capturable's SEPARATE
// `logical_size` field and never touches `physical_size`, so the rect is not
// logical. The advertised DRM geometry is physical too
// (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves
// width/height as the DRM mode). Dividing one side by the scale therefore
// compares logical against physical and rejects the valid stream on exactly
// the scaled outputs it was meant to rescue.
// logical. The advertised DRM geometry is physical too, in DELIVERED
// orientation: `augment_with_wayland_geometry` transposes width/height for a
// 90/270 output (rustdesk#15886). Whether the portal's caps arrive rotated
// is UNMEASURED on a rotated display (pipewiresrc does not apply
// SPA_META_VideoTransform), so the size half accepts either orientation
// rather than gambling a permanent offline on one of them. Dividing a side
// by the scale would still be wrong: logical against physical.
//
// The size check is what tells one connector apart from the whole-desktop
// rect the portal usually exposes. It is skipped only when BOTH sides say
@@ -537,15 +540,35 @@ pub(super) fn get_capturer_for_display(
// a monitor on a card the service cannot open is missing from the DRM list
// while the compositor still drives it.
let single_display = single_display && cap_display_info.num == 1;
// Exact orientation only: a transposed stream would be encoded at the
// PipeWire dimensions while the client keeps the advertised (rotated) ones,
// and no wayland path ever reconciles the two, so every frame would be
// rejected client-side. Falling into the bail instead advertises the display
// offline, which the client recovers from by re-enumerating.
let size_matches = advertised.width as usize == rect.1
&& advertised.height as usize == rect.2;
let transposed = advertised.width as usize == rect.2
&& advertised.height as usize == rect.1;
// The single-display carve-out forgives a size DIFFERENCE (a Full Workspace
// stream may report the workspace, not the mode), but never a transposed
// pair: that is the same served-vs-advertised orientation split as above,
// and it blanks the client the same way.
let consistent = advertised.x == rect.0 .0
&& advertised.y == rect.0 .1
&& (single_display
|| (advertised.width as usize == rect.1
&& advertised.height as usize == rect.2));
&& (size_matches || (single_display && !transposed));
if !consistent {
// Recorded so the lone-display carve-out in `mark_demoted_displays` makes
// the "advertised offline" below true for a single display too, instead of
// restart-looping against a stream nothing can serve.
super::drm_capturer::mark_fallback_rejected(display_idx);
bail!(
"drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
"drm display {} demoted with no geometry-consistent PipeWire stream{} (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
display_idx,
if transposed {
" - stream is transposed vs advertised"
} else {
""
},
advertised.width,
advertised.height,
advertised.x,