Compare commits

..

16 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
82 changed files with 2664 additions and 240 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"
@@ -1536,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
@@ -1571,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
@@ -2141,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

@@ -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

@@ -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,
}
}

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

@@ -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

@@ -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

@@ -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,