Compare commits

...

64 Commits

Author SHA1 Message Date
rustdesk
04b3e1f40b bump hbb_common: end the ICE forwarder at gathering complete, drop the closes Drop covers
hbb_common now closes the local-candidate channel when gathering
completes, so the controlled side's forwarder in spawn_webrtc_answerer
ends there, and its signaling connection to hbbs with it, instead of
sitting on a socket hbbs closed at 90s idle for the rest of the session.
It also keeps the reassembly buffer across fragmented frames.

Stream closes the WebRTC peer connection on drop (hbb_common b0b624d),
so the close_webrtc() calls in port_forward and io_loop that sat
immediately before a return or the end of scope did nothing Drop was
not about to do, while the comments beside them still said a bare drop
leaked the pc. Remove both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-03 19:02:45 +08:00
rustdesk
01b4f1cad7 web: show the WebRTC toggle and transport in the web UI
The web client now speaks WebRTC, but the desktop settings page hides
the punch options on web and the remote page opens without the session
tab that carries the transport name. Let the existing "Enable WebRTC P2P
connection" checkbox through on web (the other punch options stay
native-only), and add a Transport row to the quality monitor for WebRTC
sessions only (with "(TURN)" when ICE relayed), on every platform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXJJGEGdgu26wgCppvUXdZ
2026-09-03 18:35:06 +08:00
rustdesk
0b059b4257 bump hbb_common: decode TURN userinfo, add the webrtc_echo example
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXJJGEGdgu26wgCppvUXdZ
2026-09-03 18:35:06 +08:00
rustdesk
8fff6d55d5 webrtc: take the fork's loss recovery for sending without a congestion window
rustdesk-org/webrtc 825a0a48: without a congestion window a chunk is lost
once three chunks sent after its latest transmission are acked, counted in
send order so retransmitted chunks are covered too, and the fast retransmit
sends every lost chunk at once, as KCP nc=1 does; before, a lost
retransmission waited for T3-rtx. Also fixes the delayed SACK timer never
re-arming, the switch applying to established associations, T3-rtx
resending one chunk when the peer's window is full, and bounds new data to
1 MiB / 1024 chunks in flight like KCP's snd_wnd.

Simulated 35ms one-way, random loss both ways, 30 fps, frames later than
200ms out of 1200: 12 KB at 5% loss 996 -> 55 (KCP 61); 40 KB at 2% loss
1183 -> 20 (KCP 39).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-03 18:35:06 +08:00
rustdesk
72c445aeee webrtc: send over SCTP without a congestion window, as KCP does
The same link that streams over KCP crawls over WebRTC. webrtc-sctp runs
RFC 4960's AIMD: a fast retransmit halves cwnd, a T3 drops it to one MTU, and
slow start only rebuilds it while data is queued behind it. Where the loss is
random rather than congestion - a lossy long-haul link - the rate settles at
the Mathis ceiling MSS/(RTT*sqrt(p)) however idle the link is: about 1.3 Mbps
at 70ms RTT and 1% loss, 0.6 Mbps at 5%, while 1080p wants 2-5 Mbps. KCP's
turbo profile (nc=1) has no congestion window at all.

The fork now carries a switch that bypasses the two places gating sends on
cwnd, and hbb_common turns it on for every peer connection unless
`allow-webrtc-congestion-control` is set - the same opt-in KCP has in
`allow-kcp-congestion-control`, for the reason at `get_kcp_cc_enabled`.
Sender-side only; a browser or an older build on the other end interoperates.

Measured over a simulated link (35ms one-way, random loss both ways, 12 KB
frames at 30fps, 300 frames): at 1% loss the window stretches 9.9s of video to
20.7s with a mean latency of 5.5s; without it the stream stays realtime at a
mean of 113ms. At 3%: 47s and 15s against 10.2s and 290ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-03 18:35:06 +08:00
rustdesk
c2fd869eeb hbb_common: bump to the webrtc branch rebased on main
Picks up upstream's session-cache eviction by pc identity (#589, adopted without its
unused insert-path helper), the 90-day log retention, and the wlroots output fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-03 18:35:06 +08:00
rustdesk
0351522c60 tcp: repeat the punch across the controller's dial window
The single punch leaves before hbbs has told the controller where to dial, so
it is never in flight at the same time as the controller's SYN: it opens our
NAT, meets nothing, and a gateway that answers it with RST takes the mapping
down with it, leaving the listener waiting on a hole that no longer exists.

Punch again while the controller may still be dialing, and race those punches
against the accept. That is two ways in where there was one: the mapping is
rebuilt if a RST took it, and once the controller sits in SYN_SENT one of the
punches meets its SYN and completes as a simultaneous open - which a punch sent
before the controller had been told anything never could. The crossing reaches
the punch rather than the listener because the two sockets share the address
but only the punch matches the four-tuple, which the tests now pin down.

There is no instant to aim at, and no window either. `Client::connect` sizes
the controller's dial only after our PunchHoleSent, from its own rendezvous
time and the direct failures it has recorded for us: CONNECT_TIMEOUT between
two known-asymmetric NATs that never failed, punch_time_used times three or
six otherwise, floored at a second - so a peer that failed once dials for a
second or two from then on, and none of that reaches this side. The repeats
therefore cover our own ceiling instead, CONNECT_TIMEOUT, which is exactly as
long as the accept has always been willing to take a connection through the
hole, and back off across it: dense at the start, where every window begins
and the short ones end, sparse afterwards, which is `punch_udp`'s shape for
the same reason. A window past that ceiling was lost before this change too,
and mostly to the controller's own kernel - Windows gives a SYN up at 21s,
Linux's next re-send after 15s is at 31s; a window short of it costs a few
SYNs to a port already closed.

No punch is cut on a per-attempt timeout; one in flight is bounded only by
the shared deadline plus PUNCH_GRACE. A punch is cancel-safe only while it is
still in SYN_SENT; once the controller's SYN has crossed it the socket is half
way through a handshake, and cutting it there cuts the connection the
controller is opening - whose `connect` has already returned, so that attempt
fails outright, there being no relay fallback after a failed TCP handshake. A
timer cannot tell the two states apart, and none is needed: a gateway that
answers with RST fails the connect at once and the loop punches again, while
one that drops the SYN in silence leaves the socket in SYN_SENT, holding the
mapping open while the kernel re-sends, which any SYN of the controller's then
crosses - a second punch has nothing to add. The deadline decides whether
another punch starts; one in flight runs a grace past it, enough for a
crossing begun just before it to complete. The last sleep is cut at the
deadline rather than run out past it, so the window ends on a punch given
that grace and not on a gap of up to the backoff ceiling: the controller's
window opened after ours, on the PunchHoleSent hbbs relayed, so one as long
as ours is still open through our tail.

Only the accept races the punch, never `accept_connection`: that one does not
return until the session it goes on to run has ended, so racing it would tear a
live session down.

Whichever arrives first is the one connection the request produces. `meta`
carries the control permissions hbbs granted for this one controller, so
serving the loser as well would hand them to a second peer - and nothing about
a connection tells the two apart before `create_tcp_connection` has spoken to
it, least of all its address: a carrier NAT shares one between subscribers,
and a NAT that pools its external addresses may dial us from a different one
than hbbs saw the controller through. So the address is not checked, as
`accept_connection` never checked it; the handshake says who arrived, and what
holds the invariant is that there is no second serve. Those
permissions are a ceiling and not a grant either way: `Connection` gates every
message on `authorized`, and latches the login scope of the first request it
accepts, so a peer that reached the hole still arrives with nothing.

The accept loops rather than taking a single connection, so that a transient
accept error does not spend the window the controller still has to arrive in.

libp2p's DCUtR reaches the same place by having both peers dial at one instant
agreed over the relay. Nothing we send reaches the controller directly, so we
cover its dial window rather than name an instant inside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-03 18:35:06 +08:00
rustdesk
ca5d1e0067 webrtc: skip the controller's ICE re-send instead of queueing it twice
The controller sends every candidate twice, because the server's hop to a
peer registered over UDP can lose one. The ICE agent that dedups repeats
sits downstream of the answerer's queue, so the answerer paid for both
copies: a slot, a JSON parse, and the ICE agent's lock, once per repeat.

Remember a digest of what was queued and skip the repeat. Recorded only
once queued, so a candidate a full queue refused stays repairable by the
re-send.

The queue's depth is unchanged. A real peer gathers well under it - four
STUN servers, link-local IPv6 filtered, one component - and the drain
empties it as candidates trickle in, so what this removes is the redundant
work, not an overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-03 17:13:16 +08:00
rustdesk
c78de1c1e7 webrtc: correct the RTT variance floor to dcsctp's scaling
The earlier commit took dcsctp's min_rtt_variance = 220 as a raw floor under
rttvar. dcsctp divides the option by kHeuristicVarianceAdjustment = 8.0 first,
a historical accident it kept because downstream users had measured good
values with it, so the intended floor is 27.5ms of variance contributing 110ms
to RTO. Flooring at 220 contributed 880ms instead, which on a 50ms path left
RTO within 7% of the 1000ms default this change exists to escape.

The fork also now records why T1/T2 share T3's RTO manager here, unlike
dcsctp's separate control timers: RTO_INITIAL is the T3 value for the first
DATA chunk, since no RTT sample exists before the first SACK.
2026-09-03 17:13:16 +08:00
rustdesk
f4b366d7ba udp: make the punch prove itself, and keep the listener answering
punch_udp sent a zero-length datagram and called the hole open on whatever
arrived next. The rendezvous NAT test's own leftover replies satisfy that
immediately - connect() does not flush the receive queue - so the retry loop
never ran and success meant nothing. The dead socket then cost KCP its full
timeout to rediscover, which is how a failed punch came to take 18 seconds.

Probes now carry a magic and a 64-bit transaction id, and both ends answer
each other's probes, so returning is a fact: a reply echoing our own id is the
one thing that proves the pair carries traffic both ways. With failure now
distinguishable from 'not yet', the window drops from 20s to 3s.

Two asymmetries fall out of that:

Only the connector stops on its own acknowledgement, because only it has
something to send next. An acknowledgement proves our probe came back, not
that the peer's probe was answered - and after punch_udp returns nothing
answers probes any more, since KCP's io loop drops anything shorter than its
header. A listener that stopped there would go mute while a peer whose own
probe or answer was lost - the normal state of a hole still opening - kept
probing an endpoint that works, until it timed out.

So the listener stops on the peer's first real packet instead, and hands that
packet to KcpStream::accept as its init_packet: its arrival proves the pair as
well as an acknowledgement would, and KCP never retransmits its SYN.
2026-09-03 17:13:16 +08:00
rustdesk
70084153e9 webrtc: take dcsctp's retransmission timings and IPv6-safe MTU
webrtc-sctp ships RFC 4960's RTO.Initial/RTO.Min (3000/1000), TCP's values for
arbitrary public paths. On this workload they set the recovery time outright:
a request/response exchange keeps one chunk in flight, so no later SACK ever
raises miss_indicator to the 3 that arms fast retransmit, and the T3 floor is
the only way back. A single loss during a handshake or a first keyframe
therefore costs whole seconds.

The fork now carries dcsctp's numbers instead - the SCTP implementation Google
wrote to replace usrsctp for Chrome's WebRTC data channels, the same realtime
workload: rto_initial 500, rto_min 400, a 220ms floor under the RTT variance,
and mtu 1191. INITIAL_MTU 1228 plus DTLS/UDP/IPv6 overhead is 1313, past the
1280 minimum, so every full-size chunk fragmented on an IPv6 path.

Both patch entries move to the new branch, which also carries the Windows IPv6
byte-swap fix, so one rev matches the whole webrtc 0.13 stack.
2026-09-03 17:13:16 +08:00
rustdesk
f5780e5417 bump hbb_common: one STUN list, and drop the dead IPv4 half
`test_ipv6` kept its own hand-written copy of the STUN servers. It now reads
`WebRTCStream::stun_servers()`, so an operator who points OPTION_ICE_SERVERS at their own
server gets it on both paths instead of one.

`test_bind_ipv6` sends nothing - `connect` only makes the kernel pick a route and a source
address - so the whole cost is DNS. It races the lookups rather than betting this host's
IPv6 support on whether the first entry happens to publish a AAAA where the user resolves
from; google's does not, from a Chinese resolver, and it was the entry being bet on.

`stun_ipv4_test`, `STUNS_V4` and `test_nat_ipv4` have had no callers since the punch stopped
taking its port from a second socket, and go.

`get_kcp_cc_enabled` reads the renamed option through `option2bool`, like every other one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
e25880c281 bump hbb_common: name the family a WebRTC session runs over
`stream_type` reaches the UI as the transport that won the race, and every other transport
already carries the family in that label - the v6 punch reports `IPv6`. WebRTC does not: one
label covers both families, and it is the one path whose real remote address can differ from
the rendezvous-observed one the session is identified by.

Refine it at the hand-off to the UI rather than at the source: five sites in client.rs
compare `typ == "WebRTC"`, so widening the label there would silently move control flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
4fd93ba8f2 bump hbb_common: drop link-local IPv6 from ICE gathering
Also pin webrtc-util to a fork of 0.11.0 carrying a Windows IPv6 enumeration fix.
`ifaces` reads the adapter list's on-wire IPv6 bytes as host-order `[u16; 8]`, so on a
little-endian host every group comes out byte-swapped and unbindable: a peer's real
240e:369:9606:4600:f52a:7a8d:2530:4de0 is enumerated as e24:6903:696:46:2af5:8d7a:3025:e04d,
::1 as ::100 and fe80:: as 80fe::. Each fails to bind with WSAEADDRNOTAVAIL, so ICE gathers
no IPv6 host candidate at all on Windows - where a globally routable address is the one
NAT-free path a CGNAT'd peer has.

Never reported upstream; the unix twin of the same bug was fixed in webrtc-rs#475 (2023).
Fork: rustdesk-org/webrtc, branch rustdesk-patches, tag webrtc-util-0.11.0-win-ipv6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
4490183930 bump hbb_common: record why WebRTCStream has no Drop
Comment only. The absent impl keeps being proposed, so the reason it
cannot exist — and where ownership does live — is now on the type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
b65d32c3c6 bump hbb_common: fix the SESSIONS leak the leak test was creating
The zero-timeout cancellation in `test_cancelled_new_does_not_leak_the_pc`
is not guaranteed to win — the setup task can finish inside the single
poll it allows, and `new()` then returns a live stream that the test
discarded. `WebRTCStream` has no `Drop`, so that stranded its own pc in
SESSIONS and the test reported it as the cancelled attempt's leak.

Nothing in production was leaking. 24 tests now pass at 1, 2, 4, 8 and
default thread counts; `--test-threads=2` had failed every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
91079e7e48 bump hbb_common: sharpen the SESSIONS leak assertion
The leak test waited for an instant with no new keys, which needs the
whole suite idle; it now watches for a key that outlasts its window,
which is what "leaked" means. `--test-threads=2` still fails: a real
entry survives the wait, and it is not the one this test creates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
80ff51c590 bump hbb_common: name the punch by every transport it carries
`get_local_endpoint_trickle` became `local_endpoint() -> &str`, which
cannot fail, so both call sites lose an unreachable error arm — the
mediator's closed a pc against a failure that no longer exists.

`punch_type` named one transport, and picked it off `allow_tcp_punch`.
A round carries several at once — a NAT port and a v6 address and an
offer — and since the TCP punch became a switch it can carry none, so
one name had to misreport both: the logs of the round that broke WebRTC
read "#1 UDP punch attempt" while the request also carried the v6
address and the offer that was actually failing, and a round with
nothing to punch with was labelled "WebRTC". List them instead —
"UDP+IPv6+WebRTC" — and call the empty round "Relay", which is what it
can still end as and what `typ` prints for it.

The offer is moved into the request rather than cloned into it; that
was its last use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
5daea936ab bump hbb_common: keep ICE candidates out of the trickle offer
The offer the punch request carries grew with ICE gathering, so the
rendezvous server's `PunchHole` datagram to a UDP-registered peer
fragmented and was dropped silently. The trickle endpoint is now taken
once at construction and carries no candidates — a fixed 673 bytes.

Both sides need this: the answer travels the same encoding path, and on
the UDP-punch route it rides a datagram of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
2026-09-03 17:13:16 +08:00
rustdesk
0dce2a81a0 feat: make the TCP punch a user option, with TCP as the backstop
TCP punching was the one direct transport without a switch, while UDP,
IPv6 and WebRTC each had one. Add "Enable TCP hole punching" above the
UDP toggle on both desktop and mobile, default on — including on
self-hosted servers, since unlike the other three (whose default-off
there guards against an hbbs that cannot forward their fields) TCP
punching has always been supported by every server.

Turning all four off would leave no way to punch at all, so TCP runs
regardless in that case. That backstop keys off the switches alone: a
transport that is enabled but fails to materialize — no public v6
address, no NAT port, a failed offerer — is already covered by the
relay fallback for a round that ends up with no usable direct
transport. With the TCP punch off, the fallback request is skipped
too: it exists only to carry that punch, and would otherwise reach
connect() with nothing to try and merely open a second relay.

Known cost, unchanged behavior for the peer: the request carries no
field for this choice, so a peer that receives one with no udp_port and
no offer still punches a TCP hole and listens for a connection the
controller will not make. Representing the transport choice on the
wire needs a proto field and the server forwarding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HV43uh1ztv6Wm5qi3Y1ne
2026-09-03 17:13:16 +08:00
rustdesk
5f223617d9 fix: give the UDP NAT test a real window when the TCP clock is faked
The punch request carries udp_port only if the rendezvous server's
TestNatResponse has arrived, and the wait for it was bounded by
rtt / 2 — half the TCP connect time, on the assumption that TCP and
UDP round trips are comparable and the test, started earlier, has
already answered.

A transparent TCP proxy breaks that assumption: a TUN-mode VPN on the
host, or a redirect-mode proxy on the LAN gateway serving every device
behind it, completes the handshake locally in ~3ms while the real UDP
round trip is hundreds of ms. Log-confirmed against 5.161.65.208: ping
341ms, TCP connect 3.7ms, connect to a dead port there "succeeds" just
as fast. The window collapsed to ~1.5ms, udp_port stayed 0 on every
attempt, and UDP punch was never even requested — although UDP itself
passes such gateways untouched.

So use the TCP clock only when it is believable: below a plausible WAN
round trip it says nothing about the UDP path, and a flat ceiling
applies instead. The loop still exits the moment the port arrives, so
a genuinely nearby server pays nothing and only a UDP-dead network
waits out the ceiling — on the udp-carrying round alone, while the
parallel pure-TCP round is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HV43uh1ztv6Wm5qi3Y1ne
2026-09-03 17:13:15 +08:00
rustdesk
1aba5f2fde bump hbb_common: WebRTC peer connections own their I/O runtime
Closing the controlling window left the controlled side waiting out
ICE decay — ~25-30s in the peer's log, its disconnected/failed ladder
running to completion — where TCP delivers a FIN at once. The session
end closed the pc by spawning onto io_loop's own
`#[tokio::main(flavor = "current_thread")]` runtime, which is dropped
the moment io_loop returns, and nothing after that call yields: the
task was never polled even once, so no DTLS close_notify ever left.

Every attempt to fix that on the caller's side failed the same way,
because the mismatch was never about where the close ran: a pc's UDP
sockets register with the reactor, and its ICE/DTLS/SCTP pumps spawn
on the runtime, that is current while it is built — so a pc created
by a session outlives the only runtime that can drive its I/O, and a
close driven anywhere else completes without reaching the wire.

The bump homes them where they can outlive any caller: WebRTCStream
builds on a process-lifetime runtime and every detached close runs
there as its own never-cancelled task. io_loop keeps its plain
close_webrtc() calls and only documents why nothing here may spawn or
await the teardown on the dying session runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HV43uh1ztv6Wm5qi3Y1ne
2026-09-03 17:13:15 +08:00
rustdesk
484ba864b7 webrtc: fix race edge cases that discard or mislabel a direct connection
Three correctness fixes in the transport race, plus three convention
cleanups.

- race_transports_prefer_webrtc committed a relayed result while a direct
  attempt was still in flight: the others arm returned on
  webrtc_fut.is_none() even with an unfinished direct future, and the
  WebRTC-error arm returned a held relay without checking others_fut. A
  relay is now committed only when nothing direct can still arrive (or
  the window expires); a parked relay is also preferred over composing
  an error when both sides fail. Three regression tests, mutation-checked.

- connect()'s plain select_ok let a TURN-relayed WebRTC win as "first
  success", dropping still-racing UDP/IPv6 direct attempts and reporting
  the relayed pair as direct. It now runs through the same prefer-P2P
  race with each attempt carrying whether its path is direct, and the
  WebRTC future resolves is_relayed() so a TURN win is held behind
  direct attempts, not committed as one.

- The RelayResponse path kept direct == true when a WebRTC win's DTLS
  handshake failed and it fell back to relay, so the relay was reported
  P2P. Clear the flag with the transport switch.

- Trim the OffererGuard doc to the three-line max; move the new
  enable-webrtc localization key to the end of every lang list; the KCP
  option constant moved to hbb_common config::keys (0f663aa).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
6e78afe061 webrtc: trim the comments to AGENTS.md length; drop is_direct_transport
386 added comment lines down to 287 across client, mediator, kcp_stream
and common. Same rule as hbb_common 3d64e43: out go past-bug narration,
rejected alternatives, measurements and restatements of the code; the
non-derivable why stays.

is_direct_transport goes with them. Judging the race by a transport
label was replaced by the resolved direct flag, leaving it used only by
its own test — and, having been inserted between the doc comment and
race_transports_prefer_webrtc, it had also taken that function's
contract with it. Removing it reattaches the doc where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
bef87963e5 webrtc: judge the race by the resolved path, not the label; bound the ICE queue
Third review round. Two of these are regressions from the previous one.

- The RelayResponse race predicate was `is_direct_transport(result.2)`,
  which answers true for the label "WebRTC" - but WebRTC is only a
  direct path when ICE nominated a non-TURN pair. A TURN-relayed WebRTC
  result therefore committed instantly and cancelled the IPv6 attempt
  racing beside it, which is the same inversion the previous fix removed
  in the other direction. (That fix was also argued from a wrong premise:
  the site does carry an IPv6 future, pushed ~50 lines earlier than the
  relay one.) Each future now resolves whether its path is direct and
  the predicate reads that bool, matching the outer race, and the
  downstream recomputation goes away.

- policy_relay still folded in Config::is_proxy(), and that is what gets
  persisted into the peer's config as force-always-relay - so one
  session through a proxy pinned the peer to relay forever and disabled
  WebRTC for it, exactly the latch the previous round fixed for
  WebSocket. Split out peer_relay: the saved option or an explicit
  request for THIS peer, and the only part written back.

- The controlled side buffered remote ICE candidates in an unbounded
  channel while the controller caps the same buffer at 64, and draining
  one costs a JSON parse plus the ICE agent's lock. Whoever can reach a
  session's route could grow it without limit inside the long-lived
  service process. Bounded, with the overflow logged through the
  existing throttle.

- That route was also removed by key alone when an answerer finished, so
  a punch retry that built a fresh answerer under the same fingerprint
  had its live sender deleted by the previous one's cleanup - after
  which it received no candidates at all. Evict only our own sender, the
  way the session cache already guards the analogous case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
e585993ffb scrap/benchmark: give the Duration divisor an explicit u32
The webrtc feature pulls time 0.3 into scrap's graph (hbb_common ->
webrtc -> webrtc-dtls -> der-parser -> asn1-rs), and that crate carries
an `impl Div<time::Duration> for std::time::Duration`. Orphan rules
allow it because the RHS is its own type, and trait impls are visible
across the whole dependency graph without a use, so std::time::Duration
now has two Div candidates. `yuv_count as _` casts to a plain inference
variable, which both candidates fit, so it stops resolving:

  error[E0282]: type annotations needed
    --> libs/scrap/examples/benchmark.rs:146:33

Only two of the four sites are reported - rustc emits one E0282 per
function body - so all four are annotated. The already-explicit
`as u32` at the hwcodec site and `start.elapsed() / cnt` are unaffected,
the latter because an integer literal's variable can only unify with an
integral type and rules the time impl out on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
a1263a8c77 bump hbb_common: zero-copy receive for whole messages; document the single-reader lock
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
dbd3f04f35 bump hbb_common: Stream closes its peer connection on drop
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
c1b50f3910 webrtc: close without an await point; do not report an unknown path as direct
- close_webrtc is no longer async (hbb_common 88f965f), so the ten call
  sites in port_forward and io_loop - all inside select! arms or futures
  the UI can abandon - can no longer be cancelled mid-teardown, which
  left the pc unclosable and its session entry stranded. Client's own
  spawn_close_webrtc went with it: the runtime-teardown guard it existed
  for now lives in close_detached, so both Drop paths share one
  implementation.

- webrtc_relayed() returns None when no candidate pair is selected or
  the pc closed under a concurrent teardown, and both call sites read
  that as "not relayed", i.e. direct. A TURN-relayed session could
  therefore be shown to the user as peer-to-peer. Claiming a direct path
  needs evidence of one, so an unknown answer now counts as relayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
2639ede4c4 fix three ways ws + WebRTC could not work in practice
Review of #15684 and hbb_common#579. Each of these left the code reading
correct while the feature did not function.

- The RelayResponse race classified P2P with `result.2 == "IPv6"`, but
  that site's futures are only ever the relay ("Relay"/"WebSocket") and
  the WebRTC branch's own "WebRTC" — so the predicate was constantly
  false. When the relay landed first the result was still right (the
  webrtc arm's `others_fut.is_none()` fallback), but when WebRTC
  connected FIRST it was parked as if it were a relay and the relay was
  committed on arrival, discarding a live direct connection. That is the
  LAN case: the better the network, the worse the outcome. Classify by
  what the label means, via is_direct_transport, and test both orderings
  — only the relay-first one was covered.

- handle_peer_info wrote "force-always-relay=Y" into the peer's saved
  config whenever force_relay was set, which now includes the WebSocket
  transport. One ws session therefore turned the peer into a permanent
  relay-by-policy peer, and relay-by-policy means Relay-only ICE, so
  WebRTC could never go direct to it again — the flagship path worked
  exactly once. Persist policy_relay, which is the user's choice; the
  transport is a property of this client, not of the peer.

- The answerer gated on this machine's enable-webrtc option, but that is
  LocalConfig: the UI process writes it and never syncs it over IPC,
  while handle_punch_hole runs in the server process, which on Windows
  resolves LocalConfig under a different profile and reads the
  private-server default of "N". The gate refused to answer in exactly
  the self-hosted deployments the transport exists for. Drop it: the
  answerer follows the request, like the udp/ipv6 legs, and the option
  still gates the feature where it can — an offer only exists because
  some controller had it enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
2227f161df android: define getifaddrs/freeifaddrs for the api-21 sysroot
Turning on hbb_common's "webrtc" feature pulls webrtc-util into the android
link, and its ifaces() -- reached from vnet::Net::new() on every ICE gather --
calls getifaddrs(). bionic exports getifaddrs/freeifaddrs only from API 24,
while flutter/ndk_*.sh builds against --platform 21, so every abi failed to
link on the undefined symbols.

Raising the platform to 24 would have to drag minSdkVersion 22 with it and
turn the link error into a load-time one on Android 5.1/6.0, so define the
two symbols instead, using the RTM_GETLINK + RTM_GETADDR netlink dump bionic
itself uses. The definition also shadows bionic's on API >= 24 rather than
delegating to it, so the path that ships is the path every test device runs.

Checked against synthesised netlink dumps on the host -- link/address parsing,
prefix masks, point-to-point, ipv6 scope ids, malformed and truncated messages
-- under UBSan and byte-exact guard malloc, with a deliberately unsigned
remainder as the negative control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
654344dbd9 kcp: make the congestion-control profile opt-in, not the default
The branch had flipped KCP to nc=0 (built-in congestion window) for
every session. That is a transport-behavior change for all users made on
reasoning alone, and the reasoning does not decide it: which profile wins
depends on why packets are being lost.

nc=1 - what RustDesk has always shipped - never shrinks the send window,
so on a genuinely congested uplink it deepens the loss it is reacting to.
But nc=0's backoff is blunt: a fast retransmit halves the window while an
RTO sets cwnd = 1 outright (ikcp.c) and recovery slow-starts from one
packet, so on a link with random loss and no congestion - Wi-Fi
interference, a long-haul path - it reads loss as congestion and can
stall an interactive stream for seconds. That failure mode is also the
more visible one to a remote-desktop user.

No benchmark settles this either: a loopback A/B has no bottleneck queue,
hence no congestion to control, and would flatter nc=1 by construction.
Deciding it needs a shaped link or field data.

So keep the profile users already run and let the other one be asked for
("enable-kcp-congestion-control" = "Y"). Flipping the default later is a
one-line change once there is evidence. kcp-sys keeps its own test
covering the nc=0 path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
79f7a72fb7 bump hbb_common: remaining webrtc review fixes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
83f1662f6e bump hbb_common: webrtc receive-path review fixes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
bea0d1d2f9 ipc/auth: replace the local throttle with the shared throttled_log!
auth.rs predated hbb_common's LogThrottle and grew its own equivalent:
same shape (last_log_at + suppressed), same 5s interval, plus a helper
and three OnceLock<Mutex<..>> statics. It also counted the other way -
excluding the event being reported - so each of the three sites carried
two near-identical log::warn! arms to avoid printing "suppressed 0".

The shared macro covers all of it: one static per call site declared by
the expansion, and the multiplicity suffix appears only when there is
one, which is what those duplicated arms were for. 102 lines out, 27 in.

Behavior difference, deliberate: a burst now reads "(x47)" - the total
including this line - instead of "(suppressed 46 similar events)". One
number, no arithmetic, and one convention across the codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
6c7e3c1370 kcp: client-side integration tests over real loopback sockets
kcp-sys has been through two review rounds of behavioral fixes; the
client wrapper (kcp_io pumps, connect/accept deadlines, framed-stream
adaptation, guard lifetimes) had no tests pinning what rustdesk actually
relies on. Four now do, each through real 127.0.0.1 UDP sockets and the
BytesCodec framing sessions use:

- handshake + bidirectional framed roundtrip + graceful close: the peer
  observes end-of-stream instead of hanging (guard outlives the framed
  stream so the FIN goes out);
- a writer that queues 50 frames and closes immediately loses none of
  them - the client-side pin for the close-tail-drain semantics;
- socket errors after the peer vanishes are treated as loss: writes keep
  succeeding, nothing tears down (ICMP is advisory on connected UDP);
- the connect deadline holds when nothing answers.

Mutation-checked: dropping inbound forwarding in kcp_io reddens exactly
the three tests that need the pump, and the timeout test alone stays
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
124bbfd8dc add enable-webrtc option; gate test_ipv6 under forced relay
OPTION_ENABLE_WEBRTC (hbb_common 48c2d4d) follows the udp/ipv6 punch
options end to end: default on against the public server, off against
private ones, same settings UI placement on desktop and mobile, and the
same bool2option local-option handling. Gates:

- controller: should_create_webrtc_offerer checks it first — no pc, no
  STUN/TURN gathering, no offer in the request;
- controlled: unlike the udp/ipv6 legs, which deliberately follow the
  request, answering builds a pc that gathers ICE from this host, so
  the answerer honors this machine's own switch too.

Translations for "Enable WebRTC P2P connection" added to all 50 lang
files next to the IPv6 entry (IPv6 and WebRTC are invariant terms in
the same grammatical slot in every one of them).

Also stop probing v6 reachability (test_ipv6) under any forced relay:
the v6 punch socket is never bound there, so the probe was wasted work
on every ws/proxy/relay connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
2ba2d0a72b ws: read the all-ICE declaration from the offer envelope, drop the proto field
Companion to hbb_common 68d2729: the full-ICE declaration now lives as
an `ice_policy: "all"` key inside the webrtc:// envelope, so the request
assembly no longer sets webrtc_all_ice and the controlled side asks the
envelope (endpoint_declares_all_ice) instead of a PunchHole field. The
rendezvous server carries the offer opaquely — no forwarding to keep in
sync. Skew behavior is unchanged: an unmarked or unparseable envelope
reads as the old Relay-only semantics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
ac9e3df9a8 bump kcp-sys: 7 review fixes on rustdesk-patches (fa51c15 -> 023a006)
Reverts the connect/accept/add_conn changes that regressed concurrent
connects (the state_map guard held across add_conn is load-bearing), states
the single-conn contract on KcpEndpoint so shared-endpoint behaviour stops
consuming review effort, pins the two invariants that keep truncated input
from aborting under panic='abort', and fixes three findings from external
review: sendwnd() echoing raw config instead of KCP's effective window (a
non-positive factory value stalled sending forever), the passive closer's
lost final FIN delaying EOF by up to ~20s, and the doubled window
overflowing for extreme factory values.

Lock-only change: cargo update -p kcp-sys also re-picked libloading's
windows-targets between two versions already present in the lock; that was
reverted to keep this commit to the one line it is about. cargo metadata
--locked passes on the result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
48ef3059c7 ws: decouple ICE policy from force_relay — full-ICE WebRTC over WebSocket
WebSocket support folds into force_relay because a ws tunnel kills
classic TCP/UDP punching — but that conflated transport necessity with
relay policy, and the WebRTC decisions keyed off the merged flag: a ws
client built no offerer at all without TURN, and only a Relay-only-ICE
one with it. ws deployments could never reach a direct WebRTC
connection, which is exactly the path they are supposed to live on.

Split the flag. LoginConfigHandler now tracks policy_relay (the
force-always-relay option, an explicit relay request — /r ids and
retry-via-relay included — and proxy) separately; force_relay stays
policy_relay || use_ws() and keeps governing the classic paths, so
non-ws behavior is unchanged everywhere:

- the offerer's existence and ICE policy follow policy_relay: under
  pure ws the offer gathers every candidate type and may go direct;
  under relay-by-policy it stays Relay-only ICE, TURN-gated, exactly
  as before;
- the RelayResponse race applies the prefer-P2P window under ws (a
  direct ICE path is worth delaying an already-ready relay for) while
  policy relay keeps first-success semantics;
- the request carries webrtc_all_ice (hbb_common 64b54ab) so the
  controlled side knows the offer is full-ICE: it answers with full ICE
  and no TURN requirement, while offers without the bit keep today's
  relay-only answer path on every version-skew combination.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
759d093c28 bump kcp-sys: 14 review fixes on rustdesk-patches (6e44b93 -> fa51c15)
Picks up the handshake-recovery work plus the review round on top of it:
ABBA deadlock between the endpoint's two DashMaps, graceful-close tail
truncation, mid-stream hole on ikcp_send failure, FIN retransmission for
lost-FIN half-open hangs, SYN-ACK budget burned on dropped packets,
spurious ConnectTimeout after a completed handshake, accept-backlog
overflow stranding conns, aliasing UB in the output callback, and the
log-facade/throttling cleanup (per-packet sites no longer reach the
debug-level file logger, peer-rate warns throttled).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
770c94b639 chore: bump hbb_common — drop the reserved tag in PunchHole
9ea5442..cdcfd8d. `requester_id = 11` never reached main or hbbs, so nothing has
written or read that tag and reserving it guarded a wire format that never
existed — inconsistent with this branch retyping IceCandidate's tag 2 in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
7cfc10d932 chore: bump hbb_common to the fragment-framing fix
b7f79c6..9ea5442 — reject a fragment header that is neither FRAG_END nor
FRAG_MORE, and a FRAG_MORE carrying no payload. The latter is the one nothing
downstream caught: it adds nothing to the reassembly accumulator, so the
MAX_FRAME_LENGTH cap never trips and WebRTCStream::next() spins for as long as
the peer keeps writing, with no error and no teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
70a124696d fix: the KCP io throttle reset itself every cycle, so it never throttled
The send and recv arms shared one counter, and an ICMP error on a connected
socket is reported once and then cleared — so the steady state is an
alternation: the send succeeds and clears the counter, the next recv reports
the error and finds the counter at 1, and logs. Every error still wrote a
line, at the ~100/s the previous commit set out to stop, while the
persistent-failure and recovery branches were unreachable.

Use one LogThrottle per direction instead of a hand-rolled counter. That
removes the shared state the bug lived in, drops a third throttling mechanism
in favour of the one already added, and leaves the surrounding `if let Err`
untouched rather than reshaping it into a match.

Also fix test_udp_uat's socket-error arm, the untreated twin of the punch_udp
site: it had no backoff at all, so a persistent error re-armed recv
immediately and spun the loop at CPU speed, one warn line per iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
d76b98f7c0 fix: bound log volume on sites whose rate a peer or retry loop controls
Debug output goes to the log file, so a site that fires per received message
or per retry lets someone else decide how much a machine writes to disk. The
WebRTC work added the first such sites.

- KCP io loop: absorbing ICMP errors as packet loss made a broken socket write
  ~100 lines a second for the 60s until the pong timeout reaps it. Log by run
  instead: one line when a run starts, one per ~5s while it persists so a stuck
  socket stays visible, and one on recovery with the total.
- punch_udp: the recv error retries every 10ms for up to MAX_TIME, so one line
  per occurrence wrote thousands per punch. Log the first, report the count in
  the timeout message.
- ICE candidate paths (client, mediator): the peer sets the candidate rate and
  the rendezvous route carrying them needs no prior punch, so throttle to one
  line a minute each with the suppressed count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
2b9a6ef7b0 fix: evict the oldest pending ICE candidate, not the newest
Candidates arrive in gathering order — host, then srflx, then relay — so a
full buffer was discarding exactly the ones that traverse NAT while keeping
host ones that only work on a shared LAN. Evict from the front instead.

Also document why the controller's ICE bridge must not reconnect on error, in
contrast to the controlled side's per-candidate retry: its socket address is
the return route itself (mangled into PunchHole.socket_addr, echoed back in
IceCandidate.socket_addr, resolved through tcp_punch), so a reconnect would
arrive from an address no route points at, and the server drops the old entry
when the connection closes. Once it dies both directions are dead, and
abandoning WebRTC is the correct response rather than retrying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
362966cde9 fix: don't let the preferred branch's own relay preempt a direct fallback
race_transports_prefer_webrtc committed any success from its first argument
outright, on the assumption that it is the WebRTC connect. It is not: the call
site passes a whole punch attempt, which internally falls back to request_relay
when its direct transports fail. That relay was therefore committed instantly
while the offer-less fallback's TCP punch was still in flight — inverting the
preference this function exists to enforce, since the is_p2p predicate the
caller already supplies was applied only to the `others` branch.

Apply it to both branches: a direct result from either side still commits
immediately, and a relayed result from either side is held for the window so
the other side can land something direct. Also commit a held connection when
the surviving branch errors, which the previous code only did on the first
branch's failure path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
8da465f1d1 fix: carry switch_code through WebRTC relay fallbacks after rebase
The rebase onto master (switch-code feature) added an 8th request_relay
parameter; pass the interface's switch code from both WebRTC->relay
fallback paths so a role-swap session survives the fallback. Also drop
a duplicate bindgen 0.72.1 entry the Cargo.lock merge produced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
2026-09-03 17:13:15 +08:00
rustdesk
d45cb8a5f8 fix: KCP/UDP resilience to ICMP resets; optional KCP congestion control
- treat ICMP-driven UDP socket errors (WSAECONNRESET 10054 on Windows,
  ECONNREFUSED on Linux) as packet loss in punch_udp and the KCP pump
  instead of tearing the session down; KCP retransmits through them and a
  truly dead link is still reaped by the pong/app-level timeouts
- resolve STUN hostnames via tokio::net::lookup_host so DNS never blocks a
  runtime worker; fix the inverted non-IPv4 error message
- add enable-kcp-congestion-control option (default on): switch the turbo
  profile to nc=0 so brief loss on constrained links no longer spirals into
  stalls; sender-side only, no wire negotiation
- pin kcp-sys to the rustdesk-patches branch: upstream main lost the
  RustDesk patches on the EasyTier sync, and this branch also wires
  set_kcp_config_factory into connection setup, making the option effective

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
f5c2ff7e25 feat: decouple WebRTC from UDP punch, route controlled signaling over TCP
- the WebRTC offer now rides any punch request; only an offer-less request
  may close and reuse the rendezvous socket for TCP punching
  (request_allows_tcp_punch replaces the udp_port-based invariant), with a
  separate offer-less request racing as the TCP fallback
- WebSocket mode no longer disables WebRTC — ws only tunnels the
  signaling/relay legs while ICE stays the only P2P path there; SOCKS proxy
  still disables it (ICE would bypass the proxy and leak the real IP)
- controlled side: WebRTC-only punch replies and trickled ICE candidates go
  over dedicated TCP connections to the rendezvous server instead of the UDP
  mediator channel, for ws/TCP-only hbbs deployments; drop the now-redundant
  rz_sender plumbing and the 400ms candidate re-send on that leg
- guard is_udp handling against responses to requests that advertised no
  udp_port; skip the IPv6 socket bind under force-relay
- test_udp_uat: drop the STUN port race — the punch port must come from the
  rendezvous server's TestNatResponse observing this socket's mapping, a
  STUN probe from another socket can advertise an unreachable port
- bump hbb_common (webrtc 0.13 MSRV pin rationale + upgrade checklist docs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
86c4ddbb1e fix: preserve WebRTC transport preference 2026-09-03 17:13:15 +08:00
rustdesk
c34f29dd30 feat: WebRTC transport racing, DTLS identity binding, and pc-leak fixes
- prefer-P2P racing (race_transports_prefer_webrtc) across punch and RelayResponse; ICE bridge with 400ms candidate resend
- controlled-side answerer and ICE routing; sign local DTLS fingerprint into SignedId, controller verifies the binding fail-closed
- fix pc leaks: close_webrtc() on insecure-decline paths (io_loop, port_forward); compute direct before disarming the offerer guard
- point hbb_common to the WebRTC data-plane commit 9f5a296

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 17:13:15 +08:00
rustdesk
49dc85b9c2 fix: route WebRTC ICE through rendezvous paths 2026-09-03 17:13:15 +08:00
rustdesk
2771979eb2 feat: race WebRTC as a direct transport enhancement 2026-09-03 17:13:15 +08:00
rustdesk
de3588313a feat: route WebRTC ICE on controlled side 2026-09-03 17:13:15 +08:00
rustdesk
3c7ea8c075 feat: add rendezvous WebRTC signaling fields 2026-09-03 17:13:15 +08:00
Mariano Abad
3f93005be2 fix(drm): deliver a rotated output upright (#15886) (#15889)
* fix(drm): deliver a rotated output upright instead of sideways (#15886)

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

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

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

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

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

* refactor: trim comment density to the file norm

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

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

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

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

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

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

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

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

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

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

adversarial pass over the previous commit, three structural findings.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: bump hbb_common to main tip

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

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

---------

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:10:01 +08:00
Xinglin Qiang
23a147b0dc Filter detached DXGI outputs for Win+P single-display modes (#15814)
When Windows is set to "Show only on 1/2", DXGI still enumerates
detached outputs. Preferring that unfiltered list could select a
zero-size display as primary and hang clients waiting for video.
2026-09-03 16:04:09 +08:00
memory_clear
e4539fc304 Update cn.rs (#16041) 2026-09-03 10:08:30 +08:00
Maison da Silva
6dbd810454 Translate export-related strings to Portuguese (#16038)
Translate export-related strings to Portuguese
2026-09-03 08:51:05 +08:00
RustDesk
0fd1a0eecb Custom client no rebuild (#15774)
* feat(portable): load per-customer payload from a PE resource

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

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

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

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

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

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

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

* ci: publish msi templates for custom client builds

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* ci: build the msi template with a sentinel revision

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(portable): retry failed stale branding cleanup

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

* fix(portable): reject malformed RDPKG resources

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

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

* refact: format 2 files

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

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

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

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

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

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

* fix: generate.py, exclude-exe

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

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

This reverts commit 5104664e95.

* fix: simple path fix in generate.py

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

* Remove useless comments

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

* fix(portable): remove expect() anyway

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

* fix(portable): validate executable path boundaries

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

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

* fix, remove useless file

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-09-02 22:14:03 +08:00
Stephan Paternotte
dfb5804dd0 Update nl.rs (#16036)
Re. export and import.
Without detailed information, reference or examples from en.rs, de.rs or fr.rs, I have simply translated the three strings verbatim
2026-09-02 21:49:37 +08:00
21pages
957dfe8c96 feat: add admin and control role API scripts (#16035)
- add admin role CRUD and membership management
  - add non-protobuf control role operations

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

* Update tw.rs

* Update tw.rs
2026-09-02 14:36:49 +08:00
89 changed files with 5607 additions and 770 deletions

View File

@@ -389,6 +389,54 @@ jobs:
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
sha256sum ../../SignOutput/rustdesk-*.msi
- name: Build pre-built MSI template
# Two things this works around: preprocess.py rewrites res/msi in place, so the
# tree is reset around this second variant; and it locates the app as
# <app-name>.exe inside the dist, so the dist copy is renamed to match.
#
# The placeholder is chosen to keep this template as close to the shipped msi as
# possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX
# derives no short name for it. A longer placeholder would get one, and a patch
# cannot rewrite a truncated placeholder, leaving short names pointing at it.
#
# It still has to be unique, which is why "RustDesk" itself cannot be used:
# it also names payload that must never be renamed, such as librustdesk.dll
# and drivers\RustDeskPrinterDriver.
#
#
# Building the arm64 template on the native arm64 runner makes the ARM
# package available: the build agents are x64 and cannot run
# preprocess.py against an ARM exe.
if: env.UPLOAD_ARTIFACT == 'true'
run: |
git checkout -- res/msi
cp -r ./rustdesk ./rustdesk-msi-template
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline
$assets = './rustdesk-msi-template/data/flutter_assets/assets'
New-Item -ItemType Directory -Force -Path $assets | Out-Null
foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') {
Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline
}
pushd ./res/msi
python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
popd
mkdir ./msi-template
mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi
git checkout -- res/msi
rm -r -fo ./rustdesk-msi-template
- name: Upload unsigned msi template
if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }}
path: ./msi-template
- name: Sign rustdesk self-extracted file
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
shell: bash
@@ -925,15 +973,33 @@ jobs:
name: rustdesk-unsigned-windows-x86_64
path: ./windows-x86_64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-aarch64
path: ./windows-aarch64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-x86
path: ./windows-x86/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-x86_64
path: ./msi-template/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-aarch64
path: ./msi-template/
- name: Combine unsigned app
run: |
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
- name: Publish unsigned app
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1

302
Cargo.lock generated
View File

@@ -753,24 +753,6 @@ dependencies = [
"syn 2.0.98",
]
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags 2.9.1",
"cexpr",
"clang-sys",
"itertools 0.12.1",
"proc-macro2 1.0.93",
"quote 1.0.36",
"regex",
"rustc-hash 2.1.1",
"shlex",
"syn 2.0.98",
]
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -1161,30 +1143,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if 1.0.0",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.41"
@@ -1234,7 +1192,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@@ -2324,7 +2281,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading 0.8.4",
"libloading 0.7.4",
]
[[package]]
@@ -2442,42 +2399,6 @@ dependencies = [
"linux-raw-sys 0.6.5",
]
[[package]]
name = "dtls"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a"
dependencies = [
"aes",
"aes-gcm",
"async-trait",
"bincode",
"byteorder",
"cbc",
"ccm",
"chacha20poly1305",
"der-parser",
"hmac",
"log",
"p256",
"p384",
"portable-atomic",
"rand 0.9.2",
"rand_core 0.6.4",
"rcgen",
"ring",
"rustls",
"sec1",
"serde 1.0.228",
"sha1",
"sha2",
"thiserror 1.0.61",
"tokio",
"webrtc-util",
"x25519-dalek",
"x509-parser",
]
[[package]]
name = "dtoa"
version = "0.4.8"
@@ -2863,7 +2784,7 @@ dependencies = [
"is-terminal",
"lazy_static",
"log",
"nu-ansi-term 0.49.0",
"nu-ansi-term",
"regex",
"thiserror 1.0.61",
]
@@ -3766,6 +3687,7 @@ dependencies = [
"mac_address",
"machine-uid",
"osascript",
"percent-encoding",
"protobuf",
"protobuf-codegen",
"rand 0.8.5",
@@ -4177,16 +4099,15 @@ dependencies = [
[[package]]
name = "interceptor"
version = "0.15.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98"
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
dependencies = [
"async-trait",
"bytes",
"futures",
"log",
"portable-atomic",
"rand 0.9.2",
"rand 0.8.5",
"rtcp",
"rtp",
"thiserror 1.0.61",
@@ -4338,11 +4259,11 @@ dependencies = [
[[package]]
name = "kcp-sys"
version = "0.1.0"
source = "git+https://github.com/rustdesk-org/kcp-sys#32a6c09fc6223f54aea83981a6aa8995931d29be"
source = "git+https://github.com/rustdesk-org/kcp-sys?branch=rustdesk-patches#023a0065398968989f2ddfcf5cc72bb886d02675"
dependencies = [
"anyhow",
"auto_impl",
"bindgen 0.71.1",
"bindgen 0.72.1",
"bitflags 2.9.1",
"bytes",
"cc",
@@ -4353,8 +4274,6 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"zerocopy 0.7.34",
]
@@ -4488,7 +4407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
dependencies = [
"cfg-if 1.0.0",
"windows-targets 0.52.6",
"windows-targets 0.48.5",
]
[[package]]
@@ -5210,16 +5129,6 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
dependencies = [
"overload",
"winapi 0.3.9",
]
[[package]]
name = "nu-ansi-term"
version = "0.49.0"
@@ -5883,12 +5792,6 @@ dependencies = [
"serde_json 1.0.118",
]
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -6277,17 +6180,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -7094,9 +6986,9 @@ dependencies = [
[[package]]
name = "rtcp"
version = "0.14.0"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec"
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
dependencies = [
"bytes",
"thiserror 1.0.61",
@@ -7105,14 +6997,14 @@ dependencies = [
[[package]]
name = "rtp"
version = "0.14.0"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023"
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
dependencies = [
"bytes",
"memchr",
"portable-atomic",
"rand 0.9.2",
"rand 0.8.5",
"serde 1.0.228",
"thiserror 1.0.61",
"webrtc-util",
@@ -7267,6 +7159,7 @@ dependencies = [
"terminfo",
"termios 0.3.3",
"tiny-skia",
"tokio",
"totp-rs",
"tray-icon",
"ttf-parser",
@@ -7547,11 +7440,11 @@ dependencies = [
[[package]]
name = "sdp"
version = "0.10.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6"
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
dependencies = [
"rand 0.9.2",
"rand 0.8.5",
"substring",
"thiserror 1.0.61",
"url",
@@ -7781,15 +7674,6 @@ dependencies = [
"tzdb 0.5.10",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_library"
version = "0.1.9"
@@ -8129,15 +8013,15 @@ dependencies = [
[[package]]
name = "stun"
version = "0.9.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b"
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
dependencies = [
"base64 0.22.1",
"crc",
"lazy_static",
"md-5",
"rand 0.9.2",
"rand 0.8.5",
"ring",
"subtle",
"thiserror 1.0.61",
@@ -8519,16 +8403,6 @@ dependencies = [
"syn 2.0.98",
]
[[package]]
name = "thread_local"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
dependencies = [
"cfg-if 1.0.0",
"once_cell",
]
[[package]]
name = "threadpool"
version = "1.8.1"
@@ -8908,32 +8782,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
dependencies = [
"nu-ansi-term 0.46.0",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -9048,9 +8896,9 @@ dependencies = [
[[package]]
name = "turn"
version = "0.11.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb"
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -9058,7 +8906,7 @@ dependencies = [
"log",
"md-5",
"portable-atomic",
"rand 0.9.2",
"rand 0.8.5",
"ring",
"stun",
"thiserror 1.0.61",
@@ -9184,12 +9032,6 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
version = "0.3.15"
@@ -9335,12 +9177,6 @@ dependencies = [
"bindgen 0.65.1",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -9724,25 +9560,26 @@ dependencies = [
[[package]]
name = "webrtc"
version = "0.14.0"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad"
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
dependencies = [
"arc-swap",
"async-trait",
"bytes",
"dtls",
"cfg-if 1.0.0",
"hex",
"interceptor",
"lazy_static",
"log",
"portable-atomic",
"rand 0.9.2",
"rand 0.8.5",
"rcgen",
"regex",
"ring",
"rtcp",
"rtp",
"rustls",
"sdp",
"serde 1.0.228",
"serde_json 1.0.118",
@@ -9750,12 +9587,13 @@ dependencies = [
"smol_str",
"stun",
"thiserror 1.0.61",
"time 0.3.36",
"tokio",
"turn",
"unicase",
"url",
"waitgroup",
"webrtc-data",
"webrtc-dtls",
"webrtc-ice",
"webrtc-mdns",
"webrtc-media",
@@ -9766,9 +9604,9 @@ dependencies = [
[[package]]
name = "webrtc-data"
version = "0.12.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea"
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
dependencies = [
"bytes",
"log",
@@ -9780,17 +9618,54 @@ dependencies = [
]
[[package]]
name = "webrtc-ice"
version = "0.14.0"
name = "webrtc-dtls"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d"
checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c"
dependencies = [
"aes",
"aes-gcm",
"async-trait",
"bincode",
"byteorder",
"cbc",
"ccm",
"der-parser",
"hkdf",
"hmac",
"log",
"p256",
"p384",
"portable-atomic",
"rand 0.8.5",
"rand_core 0.6.4",
"rcgen",
"ring",
"rustls",
"sec1",
"serde 1.0.228",
"sha1",
"sha2",
"subtle",
"thiserror 1.0.61",
"tokio",
"webrtc-util",
"x25519-dalek",
"x509-parser",
]
[[package]]
name = "webrtc-ice"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
dependencies = [
"arc-swap",
"async-trait",
"crc",
"log",
"portable-atomic",
"rand 0.9.2",
"rand 0.8.5",
"serde 1.0.228",
"serde_json 1.0.118",
"stun",
@@ -9806,9 +9681,9 @@ dependencies = [
[[package]]
name = "webrtc-mdns"
version = "0.10.0"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace"
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
dependencies = [
"log",
"socket2 0.5.10",
@@ -9819,22 +9694,21 @@ dependencies = [
[[package]]
name = "webrtc-media"
version = "0.11.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773"
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
dependencies = [
"byteorder",
"bytes",
"rand 0.9.2",
"rand 0.8.5",
"rtp",
"thiserror 1.0.61",
]
[[package]]
name = "webrtc-sctp"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f"
version = "0.12.0"
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
dependencies = [
"arc-swap",
"async-trait",
@@ -9842,7 +9716,7 @@ dependencies = [
"crc",
"log",
"portable-atomic",
"rand 0.9.2",
"rand 0.8.5",
"thiserror 1.0.61",
"tokio",
"webrtc-util",
@@ -9850,9 +9724,9 @@ dependencies = [
[[package]]
name = "webrtc-srtp"
version = "0.16.0"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123"
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
dependencies = [
"aead",
"aes",
@@ -9873,19 +9747,19 @@ dependencies = [
[[package]]
name = "webrtc-util"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a"
version = "0.11.0"
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
dependencies = [
"async-trait",
"bitflags 1.3.2",
"bytes",
"ipnet",
"lazy_static",
"libc",
"log",
"nix 0.26.4",
"portable-atomic",
"rand 0.9.2",
"rand 0.8.5",
"thiserror 1.0.61",
"tokio",
"winapi 0.3.9",

View File

@@ -52,7 +52,7 @@ screencapturekit = ["cpal/screencapturekit"]
[dependencies]
async-trait = "0.1"
scrap = { path = "libs/scrap", features = ["wayland"] }
hbb_common = { path = "libs/hbb_common" }
hbb_common = { path = "libs/hbb_common", features = ["webrtc"] }
serde_derive = "1.0"
serde = "1.0"
serde_json = "1.0"
@@ -83,7 +83,7 @@ fon = "0.6"
shutdown_hooks = "0.1"
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
stunclient = "0.4"
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys", branch = "rustdesk-patches" }
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
[target.'cfg(not(target_os = "linux"))'.dependencies]
@@ -215,6 +215,16 @@ exclude = ["vdi/host"]
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
[patch.crates-io]
libxdo-sys = { path = "libs/libxdo-sys-stub" }
# One branch off upstream v0.13.0, the tag whose crate versions match this stack.
# webrtc-util: reads the Windows adapter list's IPv6 addresses as host-order u16 groups, so every
# one comes out byte-swapped, fails to bind, and ICE gathers no IPv6 host candidate on Windows.
# webrtc-sctp: RFC 4960's 1s RTO floor makes a single loss cost 1-3s on a link whose RTT is 24-64ms,
# and fast retransmit cannot cover a request/response exchange; INITIAL_MTU 1228 also fragments on
# IPv6; and its AIMD pins a lossy long-haul link to MSS/(RTT*sqrt(p)), so a switch sends without
# a congestion window, as KCP does - on by default, `allow-webrtc-congestion-control` opts back in.
# Pinned by rev, not branch: a fork branch can be rewritten out from under the lockfile.
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
[package.metadata.winres]
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
@@ -234,6 +244,7 @@ os-version = "0.2"
[dev-dependencies]
hound = "3.5"
docopt = "1.1"
tokio = { version = "1.44", features = ["test-util"] }
[package.metadata.bundle]
name = "RustDesk"

View File

@@ -43,6 +43,15 @@ fn build_manifest() {
}
}
// bionic only exports getifaddrs()/freeifaddrs() from API 24, while the jniLibs
// are built against the API 21 sysroot (flutter/ndk_*.sh). webrtc-util calls
// them, so without this the android link fails on undefined symbols.
fn build_android_ifaddrs() {
let file = "src/platform/android_ifaddrs.c";
cc::Build::new().file(file).compile("android_ifaddrs");
println!("cargo:rerun-if-changed={}", file);
}
fn install_android_deps() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
if target_os != "android" {
@@ -89,5 +98,8 @@ fn main() {
build_mac();
println!("cargo:rustc-link-lib=framework=ApplicationServices");
}
if target_os == "android" {
build_android_ifaddrs();
}
println!("cargo:rerun-if-changed=build.rs");
}

View File

@@ -1633,7 +1633,8 @@ String bool2option(String option, bool b) {
String res;
if (option.startsWith('enable-') &&
option != kOptionEnableUdpPunch &&
option != kOptionEnableIpv6Punch) {
option != kOptionEnableIpv6Punch &&
option != kOptionEnableWebrtc) {
res = b ? defaultOptionYes : 'N';
} else if (option.startsWith('allow-') ||
option == kOptionStopService ||

View File

@@ -606,6 +606,9 @@ class QualityMonitor extends StatelessWidget {
_row(
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
if (qualityMonitorModel.webrtcTransport != null)
_row("Transport",
qualityMonitorModel.webrtcTransport!),
],
),
)

View File

@@ -166,10 +166,12 @@ const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
const String kOptionStopService = "stop-service";
const String kOptionDirectxCapture = "enable-directx-capture";
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
const String kOptionEnableTcpPunch = "enable-tcp-punch";
const String kOptionEnableUdpPunch = "enable-udp-punch";
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
const String kOptionAllowSyncClipboardBetweenSessions =
"allow-sync-clipboard-between-sessions";
const String kOptionEnableWebrtc = "enable-webrtc";
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";

View File

@@ -563,6 +563,12 @@ class _GeneralState extends State<_General> {
kOptionDirectxCapture,
),
if (!isWeb && !incomingOnly) ...[
_OptionCheckBox(
context,
'Enable TCP hole punching',
kOptionEnableTcpPunch,
isServer: false,
),
_OptionCheckBox(
context,
'Enable UDP hole punching',
@@ -575,6 +581,15 @@ class _GeneralState extends State<_General> {
kOptionEnableIpv6Punch,
isServer: false,
),
],
if (!incomingOnly)
_OptionCheckBox(
context,
'Enable WebRTC P2P connection',
kOptionEnableWebrtc,
isServer: false,
),
if (!isWeb && !incomingOnly)
Tooltip(
message: translate('sync-clipboard-between-sessions-tip'),
child: _OptionCheckBox(
@@ -584,7 +599,6 @@ class _GeneralState extends State<_General> {
isServer: false,
),
),
],
];
// Add client-side wakelock option for desktop platforms

View File

@@ -97,10 +97,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _hideNetwork = false;
var _hideWebSocket = false;
var _enableTrustedDevices = false;
var _enableTcpPunch = false;
var _enableUdpPunch = false;
var _allowInsecureTlsFallback = false;
var _disableUdp = false;
var _enableIpv6Punch = false;
var _enableWebrtc = false;
var _isUsingPublicServer = false;
var _allowAskForNoteAtEndOfConnection = false;
var _preventSleepWhileConnected = true;
@@ -141,8 +143,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y' ||
isWeb;
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
_enableTcpPunch = mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
_enableWebrtc = mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
_allowAskForNoteAtEndOfConnection =
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
_preventSleepWhileConnected =
@@ -815,31 +819,65 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
});
},
),
if (!incomingOnly)
SettingsTile.switchTile(
title: Text(translate('Enable TCP hole punching')),
initialValue: _enableTcpPunch,
onToggle: isOptionFixed(kOptionEnableTcpPunch)
? null
: (v) async {
await mainSetLocalBoolOption(kOptionEnableTcpPunch, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
setState(() {
_enableTcpPunch = newValue;
});
},
),
if (!incomingOnly)
SettingsTile.switchTile(
title: Text(translate('Enable UDP hole punching')),
initialValue: _enableUdpPunch,
onToggle: (v) async {
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
setState(() {
_enableUdpPunch = newValue;
});
},
onToggle: isOptionFixed(kOptionEnableUdpPunch)
? null
: (v) async {
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
setState(() {
_enableUdpPunch = newValue;
});
},
),
if (!incomingOnly)
SettingsTile.switchTile(
title: Text(translate('Enable IPv6 P2P connection')),
initialValue: _enableIpv6Punch,
onToggle: (v) async {
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
setState(() {
_enableIpv6Punch = newValue;
});
},
onToggle: isOptionFixed(kOptionEnableIpv6Punch)
? null
: (v) async {
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
setState(() {
_enableIpv6Punch = newValue;
});
},
),
if (!incomingOnly)
SettingsTile.switchTile(
title: Text(translate('Enable WebRTC P2P connection')),
initialValue: _enableWebrtc,
onToggle: isOptionFixed(kOptionEnableWebrtc)
? null
: (v) async {
await mainSetLocalBoolOption(kOptionEnableWebrtc, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
setState(() {
_enableWebrtc = newValue;
});
},
),
SettingsTile(
title: Text(translate('Language')),

View File

@@ -3597,6 +3597,16 @@ class QualityMonitorModel with ChangeNotifier {
bool get show => _show;
QualityMonitorData get data => _data;
// Only a WebRTC session names its transport here: web has no session tab
// to show it on, and WebRTC is the one path that can be direct or TURN.
String? get webrtcTransport {
final ffiModel = parent.target?.ffiModel;
if (ffiModel == null) return null;
final streamType = ffiModel.cachedPeerData.streamType;
if (!streamType.startsWith('WebRTC')) return null;
return ffiModel.direct == false ? '$streamType (TURN)' : streamType;
}
checkShowQualityMonitor(SessionID sessionId) async {
final show = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: 'show-quality-monitor') ==

View File

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

View File

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

View File

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

View File

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

View File

@@ -143,7 +143,7 @@ fn test_vpx(
println!(
"{:?} encode: {:?}, {} byte",
codec_id,
time_sum / yuv_count as _,
time_sum / yuv_count as u32,
size / yuv_count
);
@@ -156,7 +156,7 @@ fn test_vpx(
println!(
"{:?} decode: {:?}",
codec_id,
start.elapsed() / yuv_count as _
start.elapsed() / yuv_count as u32
);
}
@@ -212,7 +212,7 @@ fn test_av1(
assert_eq!(av1s.len(), yuv_count);
println!(
"AV1 encode: {:?}, {} byte",
time_sum / yuv_count as _,
time_sum / yuv_count as u32,
size / yuv_count
);
let mut decoder = AomDecoder::new().unwrap();
@@ -221,7 +221,7 @@ fn test_av1(
let _ = decoder.decode(&av1);
let _ = decoder.flush();
}
println!("AV1 decode: {:?}", start.elapsed() / yuv_count as _);
println!("AV1 decode: {:?}", start.elapsed() / yuv_count as u32);
}
#[cfg(feature = "hwcodec")]

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -185,6 +185,14 @@ impl<T: InvokeUiSession> Remote<T> {
.unwrap()
.set_connected();
let is_secured = peer.is_secured();
// Only WebRTC needs refining: its label names the transport that won the race,
// not the family ICE ended up nominating, and it is the one path where the two
// can disagree with the address the rendezvous observed.
let stream_type = if peer.webrtc_remote_ipv6().await.unwrap_or(false) {
"WebRTC/IPv6"
} else {
stream_type
};
self.handler
.set_connection_type(is_secured, direct, stream_type); // flutter -> connection_ready
if !is_secured

View File

@@ -1,7 +1,7 @@
use std::{
collections::HashMap,
future::Future,
net::{SocketAddr, ToSocketAddrs},
net::SocketAddr,
sync::{Arc, Mutex, RwLock},
task::Poll,
};
@@ -1153,6 +1153,13 @@ pub fn is_public(url: &str) -> bool {
host == "rustdesk.com" || host.ends_with(".rustdesk.com")
}
pub fn get_tcp_punch_enabled() -> bool {
config::option2bool(
keys::OPTION_ENABLE_TCP_PUNCH,
&get_local_option(keys::OPTION_ENABLE_TCP_PUNCH),
)
}
pub fn get_udp_punch_enabled() -> bool {
config::option2bool(
keys::OPTION_ENABLE_UDP_PUNCH,
@@ -1167,9 +1174,19 @@ pub fn get_ipv6_punch_enabled() -> bool {
)
}
pub fn get_webrtc_enabled() -> bool {
config::option2bool(
keys::OPTION_ENABLE_WEBRTC,
&get_local_option(keys::OPTION_ENABLE_WEBRTC),
)
}
pub fn get_local_option(key: &str) -> String {
let v = LocalConfig::get_option(key);
if key == keys::OPTION_ENABLE_UDP_PUNCH || key == keys::OPTION_ENABLE_IPV6_PUNCH {
if key == keys::OPTION_ENABLE_UDP_PUNCH
|| key == keys::OPTION_ENABLE_IPV6_PUNCH
|| key == keys::OPTION_ENABLE_WEBRTC
{
if v.is_empty() {
if !is_public(&Config::get_rendezvous_server()) {
return "N".to_owned();
@@ -2126,11 +2143,21 @@ pub fn get_rs_pk(str_base64: &str) -> Option<sign::PublicKey> {
}
pub fn decode_id_pk(signed: &[u8], key: &sign::PublicKey) -> ResultType<(String, [u8; 32])> {
let (id, pk, _) = decode_id_pk_dtls(signed, key)?;
Ok((id, pk))
}
/// Like [`decode_id_pk`] but also returns the signed DTLS certificate fingerprint (empty string
/// for non-WebRTC peers), used to bind a WebRTC DTLS channel to the verified peer identity.
pub fn decode_id_pk_dtls(
signed: &[u8],
key: &sign::PublicKey,
) -> ResultType<(String, [u8; 32], String)> {
let res = IdPk::parse_from_bytes(
&sign::verify(signed, key).map_err(|_| anyhow!("Signature mismatch"))?,
)?;
if let Some(pk) = get_pk(&res.pk) {
Ok((res.id, pk))
Ok((res.id, pk, res.dtls_fingerprint))
} else {
bail!("Wrong their public length");
}
@@ -2432,16 +2459,26 @@ pub fn is_udp_disabled() -> bool {
Config::get_option(keys::OPTION_DISABLE_UDP) == "Y"
}
/// Run KCP with its congestion window (nc=0) instead of the turbo profile it has always shipped.
///
/// Opt-in: which profile wins depends on why packets are lost — nc=1 deepens real congestion,
/// while nc=0 reads random loss as congestion and its RTO backoff drops cwnd to 1. Undecidable
/// without a shaped link, so keep what users run today.
#[inline]
pub fn get_kcp_cc_enabled() -> bool {
let k = keys::OPTION_ALLOW_KCP_CC;
config::option2bool(k, &Config::get_option(k))
}
// this crate https://github.com/yoshd/stun-client supports nat type
async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
use std::net::ToSocketAddrs;
async fn stun_ipv6_test(stun_server: String) -> ResultType<(SocketAddr, String)> {
use stunclient::StunClient;
let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0
let socket = UdpSocket::bind(&local_addr).await?;
let Some(stun_addr) = stun_server
.to_socket_addrs()?
.filter(|x| x.is_ipv6())
.next()
// Resolve via tokio so DNS never blocks the async runtime worker.
let Some(stun_addr) = tokio::net::lookup_host(&stun_server)
.await?
.find(|x| x.is_ipv6())
else {
bail!(
"Failed to resolve STUN ipv6 server address: {}",
@@ -2451,81 +2488,36 @@ async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
let client = StunClient::new(stun_addr);
let addr = client.query_external_address_async(&socket).await?;
Ok(if addr.ip().is_ipv6() {
(addr, stun_server.to_owned())
(addr, stun_server)
} else {
bail!("STUN server returned non-IPv6 address: {}", addr)
})
}
async fn stun_ipv4_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
use std::net::ToSocketAddrs;
use stunclient::StunClient;
let local_addr = SocketAddr::from(([0u8; 4], 0));
let socket = UdpSocket::bind(&local_addr).await?;
let Some(stun_addr) = stun_server
.to_socket_addrs()?
.filter(|x| x.is_ipv4())
.next()
else {
bail!(
"Failed to resolve STUN ipv4 server address: {}",
stun_server
);
};
let client = StunClient::new(stun_addr);
let addr = client.query_external_address_async(&socket).await?;
Ok(if addr.ip().is_ipv4() {
(addr, stun_server.to_owned())
} else {
bail!("STUN server returned non-IPv6 address: {}", addr)
})
}
static STUNS_V4: [&str; 3] = [
"stun.l.google.com:19302",
"stun.cloudflare.com:3478",
"stun.nextcloud.com:3478",
];
static STUNS_V6: [&str; 3] = [
"stun.l.google.com:19302",
"stun.cloudflare.com:3478",
"stun.nextcloud.com:3478",
];
pub async fn test_nat_ipv4() -> ResultType<(SocketAddr, String)> {
use hbb_common::futures::future::{select_ok, FutureExt};
let tests = STUNS_V4
.iter()
.map(|&stun| stun_ipv4_test(stun).boxed())
.collect::<Vec<_>>();
match select_ok(tests).await {
Ok(res) => {
return Ok(res.0);
}
Err(e) => {
bail!(
"Failed to get public IPv4 address via public STUN servers: {}",
e
);
}
};
}
async fn test_bind_ipv6() -> ResultType<SocketAddr> {
use hbb_common::futures::future::FutureExt;
let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0
let socket = UdpSocket::bind(local_addr).await?;
let addr = STUNS_V6[0]
.to_socket_addrs()?
.filter(|x| x.is_ipv6())
.next()
.ok_or_else(|| {
anyhow!(
"Failed to resolve STUN ipv6 server address: {}",
STUNS_V6[0]
)
})?;
// Nothing is sent - `connect` only makes the kernel pick a route and a source address - so any
// resolvable target answers equally and the whole cost is DNS. Race the lookups rather than
// walk them: this is awaited inline on the connection path, not every STUN host publishes a
// AAAA, and one resolver that hangs must not decide whether this host has v6.
let lookups = hbb_common::webrtc::WebRTCStream::default_stun_servers()
.into_iter()
.map(|stun| {
(async move {
let addr = tokio::net::lookup_host(&stun)
.await?
.find(|x| x.is_ipv6())
.ok_or_else(|| {
anyhow!("Failed to resolve STUN ipv6 server address: {}", stun)
})?;
Ok::<SocketAddr, hbb_common::anyhow::Error>(addr)
})
.boxed()
})
.collect::<Vec<_>>();
let (addr, _) = hbb_common::futures::future::select_ok(lookups).await?;
socket.connect(addr).await?;
Ok(socket.local_addr()?)
}
@@ -2592,9 +2584,9 @@ pub async fn test_ipv6() -> Option<tokio::task::JoinHandle<()>> {
Some(tokio::spawn(async {
use hbb_common::futures::future::{select_ok, FutureExt};
let tests = STUNS_V6
.iter()
.map(|&stun| stun_ipv6_test(stun).boxed())
let tests = hbb_common::webrtc::WebRTCStream::default_stun_servers()
.into_iter()
.map(|stun| stun_ipv6_test(stun).boxed())
.collect::<Vec<_>>();
match select_ok(tests).await {
@@ -2615,51 +2607,114 @@ pub async fn test_ipv6() -> Option<tokio::task::JoinHandle<()>> {
}))
}
// A punch packet carries a magic and a transaction id so a reply can be *proven* to answer this
// probe. The punch it replaces sent a zero-length datagram and called the hole open on whatever
// arrived next - which the rendezvous NAT test's own leftover replies satisfied instantly, so the
// retry loop below never actually ran and its success meant nothing.
const PUNCH_PROBE: [u8; 4] = *b"RDP?";
const PUNCH_ACK: [u8; 4] = *b"RDP!";
const PUNCH_PACKET_LEN: usize = 12;
fn punch_packet(tag: &[u8; 4], tid: u64) -> [u8; PUNCH_PACKET_LEN] {
let mut packet = [0u8; PUNCH_PACKET_LEN];
packet[..4].copy_from_slice(tag);
packet[4..].copy_from_slice(&tid.to_le_bytes());
packet
}
fn punch_tid(packet: &[u8], tag: &[u8; 4]) -> Option<u64> {
if packet.len() != PUNCH_PACKET_LEN || packet[..4] != tag[..] {
return None;
}
packet[4..].try_into().ok().map(u64::from_le_bytes)
}
/// Punch until one of our own probes is acknowledged. Both ends run this identically - each
/// probes, each answers the other's probes - and each returns only once a reply carrying its own
/// transaction id comes back, the one thing that proves the pair carries traffic both ways.
///
/// Returning is therefore a fact rather than a guess, which is what lets the caller stop instead
/// of handing a dead socket to a transport whose only way to discover the truth is to time out.
///
/// A datagram that is neither probe nor acknowledgement is returned rather than dropped: it means
/// the peer finished first and is already speaking KCP, whose SYN is never retransmitted.
///
/// Only the connector stops on its own acknowledgement, because only it has something to send
/// next. An acknowledgement proves our probe came back, not that the peer's probe was answered -
/// and after this returns nothing answers probes any more, since KCP's io loop drops anything
/// shorter than its header. A listener that stopped here would go mute while a peer whose own
/// probe or answer was lost - the normal state of a hole that is still opening - kept probing an
/// endpoint that works, until it timed out. So the listener stops on the peer's first real packet.
pub async fn punch_udp(
socket: Arc<UdpSocket>,
listen: bool,
) -> ResultType<Option<bytes::BytesMut>> {
let tid = ((hbb_common::time_based_rand() as u64) << 32) | hbb_common::time_based_rand() as u64;
let probe = punch_packet(&PUNCH_PROBE, tid);
let mut data = [0u8; 1500];
// `connect` does not flush the receive queue, so the NAT test's extra replies are still in it.
while socket.try_recv(&mut data).is_ok() {}
let mut retry_interval = Duration::from_millis(20);
const MAX_INTERVAL: Duration = Duration::from_millis(200);
const MAX_TIME: Duration = Duration::from_secs(20);
let mut packets_sent = 0;
socket.send(&[]).await.ok();
packets_sent += 1;
// Both ends start within one rendezvous round trip of each other and the acknowledgement is
// one peer round trip, so a pair that has not answered in this long is not going to. The old
// 20s came from having no way to tell "not yet" from "never".
const MAX_TIME: Duration = Duration::from_secs(3);
let mut probes_sent = 0u32;
let mut probes_seen = 0u32;
let mut acked = false;
let mut recv_errors = 0u32;
socket.send(&probe).await.ok();
probes_sent += 1;
let mut last_send_time = Instant::now();
let tm = Instant::now();
let mut data = [0u8; 1500];
loop {
tokio::select! {
_ = hbb_common::sleep(retry_interval.as_secs_f32()) => {
if tm.elapsed() > MAX_TIME {
bail!("UDP punch is timed out, stop sending packets after {:?} packets", packets_sent);
bail!("UDP punch is timed out, {probes_sent} probes sent, {probes_seen} probes received, acked: {acked}, {recv_errors} recv errors absorbed");
}
let elapsed = last_send_time.elapsed();
if elapsed >= retry_interval {
socket.send(&[]).await.ok();
packets_sent += 1;
// Exponentially increase interval to reduce network pressure
retry_interval = std::cmp::min(
Duration::from_millis((retry_interval.as_millis() as f64 * 1.5) as u64),
MAX_INTERVAL
);
if last_send_time.elapsed() >= retry_interval {
socket.send(&probe).await.ok();
probes_sent += 1;
retry_interval = std::cmp::min(retry_interval.mul_f64(1.5), MAX_INTERVAL);
last_send_time = Instant::now();
}
}
res = socket.recv(&mut data) => match res {
Err(e) => bail!("UDP punch failed, {packets_sent} packets sent: {e}"),
Err(e) => {
// ICMP unreachable from the peer's NAT is expected while the hole forms and
// surfaces here as ConnectionReset/Refused; treat it as loss, MAX_TIME bounds
// the attempt. Log only the first - this retries every 10ms.
recv_errors += 1;
if recv_errors == 1 {
log::debug!("UDP punch recv error (treated as loss): {e}");
}
hbb_common::sleep(0.01).await;
}
Ok(n) => {
// log::debug!("UDP punch succeeded after sending {} packets after {:?}", packets_sent, tm.elapsed());
if listen {
if n == 0 {
continue;
let ack = punch_tid(&data[..n], &PUNCH_ACK);
if ack == Some(tid) {
if !listen {
log::debug!(
"UDP punch confirmed in {:?}, {probes_sent} probes sent, {probes_seen} received",
tm.elapsed()
);
return Ok(None);
}
acked = true;
} else if let Some(peer_tid) = punch_tid(&data[..n], &PUNCH_PROBE) {
probes_seen += 1;
socket.send(&punch_packet(&PUNCH_ACK, peer_tid)).await.ok();
} else if ack.is_none() && n > 0 {
log::debug!(
"UDP punch confirmed by {n} bytes of peer data in {:?}, {probes_sent} probes sent",
tm.elapsed()
);
return Ok(Some(bytes::BytesMut::from(&data[..n])));
}
return Ok(None);
}
}
}

View File

@@ -24,7 +24,6 @@ use std::os::windows::io::AsRawHandle;
use std::{
fs,
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
#[cfg(windows)]
use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId};
@@ -520,66 +519,17 @@ pub(crate) fn ensure_peer_executable_matches_current_by_fd(
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
const UNAUTHORIZED_IPC_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
#[derive(Default)]
struct UnauthorizedIpcLogThrottle {
last_log_at: Option<std::time::Instant>,
suppressed: u64,
}
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
impl UnauthorizedIpcLogThrottle {
#[inline]
fn on_reject(&mut self, now: std::time::Instant) -> Option<u64> {
if let Some(last) = self.last_log_at {
if now.saturating_duration_since(last) < UNAUTHORIZED_IPC_LOG_INTERVAL {
self.suppressed += 1;
return None;
}
}
self.last_log_at = Some(now);
Some(std::mem::take(&mut self.suppressed))
}
}
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
#[inline]
fn throttled_unauthorized_ipc_log(
throttle_cell: &OnceLock<Mutex<UnauthorizedIpcLogThrottle>>,
emit: impl FnOnce(u64),
) {
let throttle = throttle_cell.get_or_init(|| Mutex::new(UnauthorizedIpcLogThrottle::default()));
let should_log = match throttle.lock() {
Ok(mut throttle) => throttle.on_reject(std::time::Instant::now()),
Err(_) => Some(0),
};
if let Some(suppressed) = should_log {
emit(suppressed);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[inline]
fn log_rejected_service_connection(postfix: &str, peer_uid: Option<u32>, active_uid: Option<u32>) {
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
if suppressed > 0 {
log::warn!(
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)",
postfix,
peer_uid,
active_uid,
suppressed
);
} else {
log::warn!(
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}",
postfix,
peer_uid,
active_uid
);
}
});
hbb_common::throttled_log!(
UNAUTHORIZED_IPC_LOG_INTERVAL,
warn,
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}",
postfix,
peer_uid,
active_uid
);
}
#[cfg(target_os = "linux")]
@@ -589,25 +539,14 @@ pub(crate) fn log_rejected_uinput_connection(
peer_uid: Option<u32>,
active_uid: Option<u32>,
) {
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
if suppressed > 0 {
log::warn!(
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)",
postfix,
peer_uid,
active_uid,
suppressed
);
} else {
log::warn!(
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}",
postfix,
peer_uid,
active_uid
);
}
});
hbb_common::throttled_log!(
UNAUTHORIZED_IPC_LOG_INTERVAL,
warn,
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}",
postfix,
peer_uid,
active_uid
);
}
#[cfg(windows)]
@@ -620,31 +559,17 @@ pub(crate) fn log_rejected_windows_ipc_connection(
peer_is_system: Option<bool>,
peer_is_elevated: Option<bool>,
) {
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
if suppressed > 0 {
log::warn!(
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?} (suppressed {} similar events)",
postfix,
peer_pid,
peer_session_id,
expected_session_id,
peer_is_system,
peer_is_elevated,
suppressed
);
} else {
log::warn!(
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}",
postfix,
peer_pid,
peer_session_id,
expected_session_id,
peer_is_system,
peer_is_elevated
);
}
});
hbb_common::throttled_log!(
UNAUTHORIZED_IPC_LOG_INTERVAL,
warn,
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}",
postfix,
peer_pid,
peer_session_id,
expected_session_id,
peer_is_system,
peer_is_elevated
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]

View File

@@ -19,7 +19,28 @@ pub struct KcpStream {
stop_sender: Option<oneshot::Sender<()>>,
}
const KCP_IO_ERR_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
static KCP_SEND_ERR_LOG: hbb_common::log_throttle::LogThrottle =
hbb_common::log_throttle::LogThrottle::new(KCP_IO_ERR_LOG_INTERVAL);
static KCP_RECV_ERR_LOG: hbb_common::log_throttle::LogThrottle =
hbb_common::log_throttle::LogThrottle::new(KCP_IO_ERR_LOG_INTERVAL);
impl KcpStream {
// Opt in to KCP's built-in congestion window (nc=0) instead of the pure turbo profile
// (nc=1) that has always shipped; see `get_kcp_cc_enabled` for why this is not the default.
// Sender-side only, so no wire negotiation is needed and either peer may run either profile.
// Requires kcp-sys from the `rustdesk-patches` branch, which wires the config factory into
// connection setup (on older revs the factory was stored but never consulted).
fn apply_kcp_config(endpoint: &mut KcpEndpoint) {
if crate::get_kcp_cc_enabled() {
endpoint.set_kcp_config_factory(Box::new(|conv| {
let mut config = kcp_sys::ffi_safe::KcpConfig::new_turbo(conv);
config.nc = Some(0);
config
}));
}
}
fn create_framed(stream: stream::KcpStream, local_addr: Option<SocketAddr>) -> Stream {
Stream::Tcp(FramedStream(
tokio_util::codec::Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()),
@@ -35,6 +56,7 @@ impl KcpStream {
init_packet: Option<BytesMut>,
) -> ResultType<(Self, Stream)> {
let mut endpoint = KcpEndpoint::new();
Self::apply_kcp_config(&mut endpoint);
endpoint.run().await;
let (input, output) = (
@@ -70,6 +92,7 @@ impl KcpStream {
timeout: std::time::Duration,
) -> ResultType<(Self, Stream)> {
let mut endpoint = KcpEndpoint::new();
Self::apply_kcp_config(&mut endpoint);
endpoint.run().await;
let (input, output) = (
@@ -104,6 +127,10 @@ impl KcpStream {
let udp = udp_socket.clone();
tokio::spawn(async move {
let mut buf = vec![0; 1500];
// Socket errors are ICMP unreachable on a connected UDP socket — advisory, and
// routine while a hole forms — so treat them as loss and let KCP's pong timeout reap
// a link that is really dead. One throttle PER DIRECTION: the error is reported once
// and cleared, so send-ok/recv-err alternates and a shared counter never fires.
loop {
tokio::select! {
_ = &mut stop_receiver => {
@@ -112,8 +139,10 @@ impl KcpStream {
}
Some(data) = output.recv() => {
if let Err(e) = udp.send(&data.inner()).await {
log::debug!("KCP send error: {:?}", e);
break;
if let Some(n) = KCP_SEND_ERR_LOG.due() {
log::debug!("KCP send error x{n} (treated as loss), last: {e}");
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
result = udp.recv_from(&mut buf) => {
@@ -127,8 +156,10 @@ impl KcpStream {
.await.ok();
}
Err(e) => {
log::debug!("KCP recv_from error: {:?}", e);
break;
if let Some(n) = KCP_RECV_ERR_LOG.due() {
log::debug!("KCP recv error x{n} (treated as loss), last: {e}");
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
@@ -149,3 +180,124 @@ impl Drop for KcpStream {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
async fn connected_pair() -> (Arc<UdpSocket>, Arc<UdpSocket>) {
let a = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let b = UdpSocket::bind("127.0.0.1:0").await.unwrap();
a.connect(b.local_addr().unwrap()).await.unwrap();
b.connect(a.local_addr().unwrap()).await.unwrap();
(Arc::new(a), Arc::new(b))
}
async fn establish() -> ((KcpStream, Stream), (KcpStream, Stream)) {
let (a, b) = connected_pair().await;
let (accept_res, connect_res) = tokio::join!(
KcpStream::accept(b, Duration::from_secs(5), None),
KcpStream::connect(a, Duration::from_secs(5))
);
(
connect_res.expect("connect over loopback"),
accept_res.expect("accept over loopback"),
)
}
// The full client path over real loopback sockets: handshake through the kcp_io
// pumps, framed data both ways, then a graceful close. The endpoint guard stays
// alive across the stream drop so the FIN can go out, and the peer's framed
// stream must end (BrokenPipe from the kcp reader) instead of hanging.
#[tokio::test]
async fn test_kcp_stream_loopback_roundtrip_and_close() {
let ((_guard_a, mut stream_a), (_guard_b, mut stream_b)) = establish().await;
stream_a
.send_bytes(Bytes::from_static(b"ping"))
.await
.unwrap();
let got = stream_b.next_timeout(5000).await.unwrap().unwrap();
assert_eq!(&got[..], b"ping");
stream_b
.send_bytes(Bytes::from_static(b"pong"))
.await
.unwrap();
let got = stream_a.next_timeout(5000).await.unwrap().unwrap();
assert_eq!(&got[..], b"pong");
drop(stream_a);
match stream_b.next_timeout(10_000).await {
None | Some(Err(_)) => {}
Some(Ok(data)) => panic!("unexpected data after close: {:?}", data),
}
}
// A writer that queues many frames and closes immediately must not cost the
// reader any of them: every frame arrives intact, in order, before end-of-stream.
// This is the client-side pin for the kcp-sys close-tail-drain semantics, through
// the real BytesCodec framing rustdesk sessions use.
#[tokio::test]
async fn test_kcp_stream_close_delivers_all_frames() {
let ((_guard_a, mut tx), (_guard_b, mut rx)) = establish().await;
const N: usize = 50;
let payload = vec![7u8; 32 * 1024];
for _ in 0..N {
tx.send_bytes(Bytes::from(payload.clone())).await.unwrap();
}
drop(tx);
let mut got = 0usize;
loop {
match rx.next_timeout(10_000).await {
Some(Ok(data)) => {
assert_eq!(data.len(), payload.len(), "frame boundary broken");
assert!(data.iter().all(|&b| b == 7), "frame content corrupted");
got += 1;
}
// BrokenPipe (kcp reader end) or timeout-None both end the stream.
None | Some(Err(_)) => break,
}
}
assert_eq!(got, N, "graceful close lost frames");
}
// Socket errors on the connected UDP socket (ICMP unreachable after the peer
// vanishes) are advisory: the io loop must treat them as loss - keep accepting
// writes, keep running - rather than tearing the session down. Whether the OS
// actually surfaces ECONNREFUSED here is platform-dependent; either way the
// session must stay alive for this window.
#[tokio::test]
async fn test_kcp_io_treats_socket_errors_as_loss() {
let ((_guard_a, mut stream_a), (guard_b, stream_b)) = establish().await;
// Kill the peer entirely: endpoint stops, socket closes.
drop(stream_b);
drop(guard_b);
tokio::time::sleep(Duration::from_millis(50)).await;
for _ in 0..10 {
stream_a
.send_bytes(Bytes::from_static(b"into the void"))
.await
.expect("socket errors must be treated as loss, not stream failure");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
// The connect deadline must hold when nothing answers: no hang, prompt error.
#[tokio::test]
async fn test_kcp_connect_timeout_without_peer() {
let (a, _b) = connected_pair().await;
let start = tokio::time::Instant::now();
let res = KcpStream::connect(a, Duration::from_millis(600)).await;
assert!(res.is_err(), "connect must fail with no peer endpoint");
assert!(
start.elapsed() < Duration::from_secs(5),
"connect did not honor its deadline"
);
}
}

View File

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

View File

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

View File

@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "Заключване на платното"),
("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"),
("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
("Enable WebRTC P2P connection", "Позволяване на WebRTC P2P връзка"),
("Enable TCP hole punching", "Позволяване на TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Habilita la connexió WebRTC P2P"),
("Enable TCP hole punching", "Activa la perforació TCP"),
].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,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", "锁定画布"),
("Sync clipboard between sessions", "在会话间同步剪贴板"),
("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
("Enable WebRTC P2P connection", "启用 WebRTC P2P 连接"),
("Enable TCP hole punching", "启用 TCP 打洞"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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í."),
("Enable WebRTC P2P connection", "Povolit připojení WebRTC P2P"),
("Enable TCP hole punching", "Povolit TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Aktivér WebRTC P2P-forbindelse"),
("Enable TCP hole punching", "Aktivér TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "WebRTC-P2P-Verbindung aktivieren"),
("Enable TCP hole punching", "TCP-Hole-Punching aktivieren"),
].iter().cloned().collect();
}

View File

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

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Ebligi WebRTC P2P-konekton"),
("Enable TCP hole punching", "Ebligi TCP-trapikadon"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Habilitar conexión WebRTC P2P"),
("Enable TCP hole punching", "Habilitar perforación de agujero TCP"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Luba WebRTC P2P-ühendus"),
("Enable TCP hole punching", "Luba TCP-augustamine"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Gaitu WebRTC P2P konexioa"),
("Enable TCP hole punching", "Gaitu TCP zulo-egitea"),
].iter().cloned().collect();
}

View File

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

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Ota WebRTC P2P yhteys käyttöön"),
("Enable TCP hole punching", "Ota käyttöön TCP hole punching tekniikka"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Activer la connexion P2P WebRTC"),
("Enable TCP hole punching", "Activer le « hole punching » TCP"),
].iter().cloned().collect();
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Omogući WebRTC P2P vezu"),
("Enable TCP hole punching", "Omogući TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "WebRTC P2P kapcsolat engedélyezése"),
("Enable TCP hole punching", "TCP résszűrés engedélyezése"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Aktifkan koneksi P2P WebRTC"),
("Enable TCP hole punching", "Aktifkan TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Abilita connessione P2P WebRTC"),
("Enable TCP hole punching", "Abilita hole punching TCP"),
].iter().cloned().collect();
}

View File

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

View File

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

View File

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

View File

@@ -763,5 +763,7 @@ 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ę."),
("Enable WebRTC P2P connection", "Įgalinti WebRTC P2P ryšį"),
("Enable TCP hole punching", "Įgalinti TCP gręžimą (hole punching)"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Iespējot WebRTC P2P savienojumu"),
("Enable TCP hole punching", "Iespējot TCP caurumu veidošanu"),
].iter().cloned().collect();
}

View File

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

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Aktiver WebRTC P2P-tilkobling"),
("Enable TCP hole punching", "Aktiver TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."),
("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."),
("Save as", "Opslaan als"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Export", "Exporteren"),
("Export Logs", "Logboeken exporteren"),
("Import Folder", "Map importeren"),
("Copy to clipboard", "Kopiëren naar het klembord"),
("Enable remote printer", "Printer op afstand inschakelen"),
("Downloading {}", "Downloaden {}"),
@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "WebRTC P2P-verbinding inschakelen"),
("Enable TCP hole punching", "TCP-hole punching inschakelen"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Włącz połączenie P2P WebRTC"),
("Enable TCP hole punching", "Włącz tworzenie tunelu TCP"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Ativar ligação P2P por WebRTC"),
("Enable TCP hole punching", "Ativar TCP hole punching"),
].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,7 @@ 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."),
("Enable WebRTC P2P connection", "Habilitar conexão WebRTC P2P"),
("Enable TCP hole punching", "Habilitar TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Activează conexiunea P2P prin WebRTC"),
("Enable TCP hole punching", "Activează traversarea TCP (hole punching)"),
].iter().cloned().collect();
}

View File

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

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Abìlita connessione P2P WebRTC"),
("Enable TCP hole punching", "Abìlita s'istampadura TCP"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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í."),
("Enable WebRTC P2P connection", "Povoliť pripojenie WebRTC P2P"),
("Enable TCP hole punching", "Povoliť TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Omogoči povezavo WebRTC P2P"),
("Enable TCP hole punching", "Omogoči preboj lukenj TCP"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Aktivizo lidhjen WebRTC P2P"),
("Enable TCP hole punching", "Aktivizo TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Omogući WebRTC P2P konekciju"),
("Enable TCP hole punching", "Omogući TCP hole punching"),
].iter().cloned().collect();
}

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Aktivera WebRTC P2P anslutning"),
("Enable TCP hole punching", "Aktivera TCP hålslagning"),
].iter().cloned().collect();
}

View File

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

View File

@@ -763,5 +763,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Lock canvas", ""),
("Sync clipboard between sessions", ""),
("sync-clipboard-between-sessions-tip", ""),
("Enable WebRTC P2P connection", ""),
("Enable TCP hole punching", ""),
].iter().cloned().collect();
}

View File

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

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "WebRTC P2P bağlantısını etkinleştir"),
("Enable TCP hole punching", "TCP delik açmayı etkinleştir"),
].iter().cloned().collect();
}

View File

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

View File

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

View File

@@ -763,5 +763,7 @@ 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."),
("Enable WebRTC P2P connection", "Cho phép kết nối WebRTC P2P"),
("Enable TCP hole punching", "Bật TCP Hole Punching"),
].iter().cloned().collect();
}

View File

@@ -0,0 +1,404 @@
/*
* getifaddrs()/freeifaddrs() for Android: bionic only exports them from API 24,
* while the jniLibs are built against the API 21 sysroot (flutter/ndk_*.sh) and
* webrtc-util calls them whenever WebRTC gathers ICE candidates.
*
* Only AF_INET and AF_INET6 entries are reported; the AF_PACKET ones the real
* getifaddrs() also returns have no reader in this build.
*/
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
/* Refuse a single netlink datagram larger than this rather than grow forever. */
#define RD_NL_MAX_BUF (1024 * 1024)
/* A dump that never terminates must not hang the caller. */
#define RD_NL_MAX_DATAGRAMS 4096
typedef int (*rd_nl_cb)(struct nlmsghdr *nlh, void *ctx);
struct rd_link_info {
unsigned int index;
unsigned int flags;
char name[IFNAMSIZ + 1];
};
struct rd_link_table {
struct rd_link_info *items;
size_t len;
size_t cap;
};
/* One allocation per reported address; `ifa` first so freeifaddrs() can free
* the node it is handed. */
struct rd_ifaddrs_storage {
struct ifaddrs ifa;
struct sockaddr_storage addr;
struct sockaddr_storage netmask;
struct sockaddr_storage ifu;
char name[IFNAMSIZ + 1];
};
struct rd_addr_ctx {
const struct rd_link_table *links;
struct ifaddrs *head;
struct ifaddrs *tail;
};
static void rd_parse_rtattr(struct rtattr *rta, int len, struct rtattr **tb, int max)
{
memset(tb, 0, sizeof(*tb) * ((size_t)max + 1));
for (; RTA_OK(rta, len); rta = RTA_NEXT(rta, len)) {
if (rta->rta_type <= (unsigned short)max && tb[rta->rta_type] == NULL)
tb[rta->rta_type] = rta;
}
}
/* `len` must stay signed: NLMSG_NEXT subtracts the *aligned* length, which
* overshoots on an unaligned trailing message, and only a negative remainder
* stops NLMSG_OK from reading past the buffer. */
static int rd_nl_parse(char *buf, int len, unsigned short reply_type, unsigned int seq,
rd_nl_cb cb, void *ctx, int *done)
{
struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
for (; NLMSG_OK(nlh, len); nlh = NLMSG_NEXT(nlh, len)) {
if (nlh->nlmsg_seq != seq)
continue;
if (nlh->nlmsg_type == NLMSG_DONE) {
*done = 1;
return 0;
}
if (nlh->nlmsg_type == NLMSG_ERROR) {
struct nlmsgerr *err = (struct nlmsgerr *)NLMSG_DATA(nlh);
if (nlh->nlmsg_len >= NLMSG_LENGTH(sizeof(*err)) && err->error != 0)
errno = -err->error;
else
errno = EIO;
return -1;
}
if (nlh->nlmsg_type != reply_type)
continue;
if (cb(nlh, ctx) != 0)
return -1;
/* A non-multipart reply is the whole answer; nothing follows it. */
if ((nlh->nlmsg_flags & NLM_F_MULTI) == 0) {
*done = 1;
return 0;
}
}
return 0;
}
static int rd_nl_dump(int fd, unsigned short request_type, unsigned short reply_type,
unsigned int seq, rd_nl_cb cb, void *ctx)
{
struct {
struct nlmsghdr nlh;
struct rtgenmsg gen;
} req;
struct sockaddr_nl kernel;
char *buf;
size_t cap = 8192;
int datagrams = 0;
int done = 0;
int rc = -1;
int saved;
memset(&req, 0, sizeof(req));
req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(req.gen));
req.nlh.nlmsg_type = request_type;
req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
req.nlh.nlmsg_seq = seq;
req.gen.rtgen_family = AF_UNSPEC;
memset(&kernel, 0, sizeof(kernel));
kernel.nl_family = AF_NETLINK;
for (;;) {
if (sendto(fd, &req, req.nlh.nlmsg_len, 0, (struct sockaddr *)&kernel,
sizeof(kernel)) >= 0)
break;
if (errno != EINTR)
return -1;
}
buf = (char *)malloc(cap);
if (buf == NULL) {
errno = ENOMEM;
return -1;
}
while (!done) {
/* MSG_PEEK|MSG_TRUNC reports the datagram's real size, so an
* undersized buffer costs a resize instead of a silent truncation. */
ssize_t n = recv(fd, buf, cap, MSG_PEEK | MSG_TRUNC);
if (n < 0) {
if (errno == EINTR)
continue;
goto out;
}
if ((size_t)n > cap) {
char *grown;
if ((size_t)n > RD_NL_MAX_BUF) {
errno = EMSGSIZE;
goto out;
}
grown = (char *)realloc(buf, (size_t)n);
if (grown == NULL) {
errno = ENOMEM;
goto out;
}
buf = grown;
cap = (size_t)n;
continue;
}
n = recv(fd, buf, cap, 0);
if (n < 0) {
if (errno == EINTR)
continue;
goto out;
}
if (n == 0 || ++datagrams > RD_NL_MAX_DATAGRAMS) {
errno = EIO;
goto out;
}
if (rd_nl_parse(buf, (int)n, reply_type, seq, cb, ctx, &done) != 0)
goto out;
}
rc = 0;
out:
saved = errno;
free(buf);
errno = saved;
return rc;
}
static int rd_link_cb(struct nlmsghdr *nlh, void *ctx)
{
struct rd_link_table *t = (struct rd_link_table *)ctx;
struct ifinfomsg *ifi;
struct rtattr *tb[IFLA_IFNAME + 1];
struct rd_link_info *slot;
int payload;
int namelen;
if (nlh->nlmsg_len < NLMSG_LENGTH(sizeof(*ifi)))
return 0;
ifi = (struct ifinfomsg *)NLMSG_DATA(nlh);
payload = (int)nlh->nlmsg_len - (int)NLMSG_SPACE(sizeof(*ifi));
if (payload < 0)
payload = 0;
rd_parse_rtattr(IFLA_RTA(ifi), payload, tb, IFLA_IFNAME);
/* An interface we cannot name is of no use: callers dereference ifa_name. */
if (tb[IFLA_IFNAME] == NULL || (int)RTA_PAYLOAD(tb[IFLA_IFNAME]) <= 0)
return 0;
if (t->len == t->cap) {
size_t ncap = t->cap ? t->cap * 2 : 16;
struct rd_link_info *items =
(struct rd_link_info *)realloc(t->items, ncap * sizeof(*items));
if (items == NULL) {
errno = ENOMEM;
return -1;
}
t->items = items;
t->cap = ncap;
}
slot = &t->items[t->len];
memset(slot, 0, sizeof(*slot));
slot->index = (unsigned int)ifi->ifi_index;
slot->flags = ifi->ifi_flags;
namelen = (int)RTA_PAYLOAD(tb[IFLA_IFNAME]);
if (namelen > IFNAMSIZ)
namelen = IFNAMSIZ;
memcpy(slot->name, RTA_DATA(tb[IFLA_IFNAME]), (size_t)namelen);
slot->name[namelen] = '\0';
t->len++;
return 0;
}
static const struct rd_link_info *rd_link_find(const struct rd_link_table *t,
unsigned int index)
{
size_t i;
for (i = 0; i < t->len; i++) {
if (t->items[i].index == index)
return &t->items[i];
}
return NULL;
}
static void rd_fill_mask(unsigned char *out, int len, unsigned int prefix)
{
int i;
if (prefix > (unsigned int)len * 8)
prefix = (unsigned int)len * 8;
for (i = 0; i < len; i++) {
if (prefix >= 8) {
out[i] = 0xff;
prefix -= 8;
} else if (prefix > 0) {
out[i] = (unsigned char)(0xff << (8 - prefix));
prefix = 0;
} else {
out[i] = 0;
}
}
}
static void rd_set_in(struct sockaddr_storage *ss, const void *addr)
{
struct sockaddr_in *sin = (struct sockaddr_in *)ss;
sin->sin_family = AF_INET;
memcpy(&sin->sin_addr, addr, 4);
}
static int rd_addr_cb(struct nlmsghdr *nlh, void *ctx)
{
struct rd_addr_ctx *c = (struct rd_addr_ctx *)ctx;
struct ifaddrmsg *ifa;
struct rtattr *tb[IFA_BROADCAST + 1];
struct rtattr *ra;
const struct rd_link_info *link;
struct rd_ifaddrs_storage *st;
int payload;
if (nlh->nlmsg_len < NLMSG_LENGTH(sizeof(*ifa)))
return 0;
ifa = (struct ifaddrmsg *)NLMSG_DATA(nlh);
if (ifa->ifa_family != AF_INET && ifa->ifa_family != AF_INET6)
return 0;
/* Without the link entry there is no name, and callers deref ifa_name. */
link = rd_link_find(c->links, ifa->ifa_index);
if (link == NULL)
return 0;
payload = (int)nlh->nlmsg_len - (int)NLMSG_SPACE(sizeof(*ifa));
if (payload < 0)
payload = 0;
rd_parse_rtattr(IFA_RTA(ifa), payload, tb, IFA_BROADCAST);
/* On a point-to-point link IFA_ADDRESS holds the peer and IFA_LOCAL the
* local address; ipv6 only ever sets IFA_ADDRESS. */
if (ifa->ifa_family == AF_INET)
ra = tb[IFA_LOCAL] ? tb[IFA_LOCAL] : tb[IFA_ADDRESS];
else
ra = tb[IFA_ADDRESS] ? tb[IFA_ADDRESS] : tb[IFA_LOCAL];
if (ra == NULL)
return 0;
if ((int)RTA_PAYLOAD(ra) < (ifa->ifa_family == AF_INET ? 4 : 16))
return 0;
st = (struct rd_ifaddrs_storage *)calloc(1, sizeof(*st));
if (st == NULL) {
errno = ENOMEM;
return -1;
}
memcpy(st->name, link->name, sizeof(st->name));
st->ifa.ifa_name = st->name;
st->ifa.ifa_flags = link->flags;
st->ifa.ifa_addr = (struct sockaddr *)&st->addr;
st->ifa.ifa_netmask = (struct sockaddr *)&st->netmask;
if (ifa->ifa_family == AF_INET) {
struct sockaddr_in *mask = (struct sockaddr_in *)&st->netmask;
rd_set_in(&st->addr, RTA_DATA(ra));
mask->sin_family = AF_INET;
rd_fill_mask((unsigned char *)&mask->sin_addr, 4, ifa->ifa_prefixlen);
if ((link->flags & IFF_POINTOPOINT) && tb[IFA_ADDRESS] && tb[IFA_LOCAL] &&
(int)RTA_PAYLOAD(tb[IFA_ADDRESS]) >= 4 &&
memcmp(RTA_DATA(tb[IFA_ADDRESS]), RTA_DATA(tb[IFA_LOCAL]), 4) != 0) {
rd_set_in(&st->ifu, RTA_DATA(tb[IFA_ADDRESS]));
st->ifa.ifa_dstaddr = (struct sockaddr *)&st->ifu;
} else if (tb[IFA_BROADCAST] && (int)RTA_PAYLOAD(tb[IFA_BROADCAST]) >= 4) {
rd_set_in(&st->ifu, RTA_DATA(tb[IFA_BROADCAST]));
st->ifa.ifa_broadaddr = (struct sockaddr *)&st->ifu;
}
} else {
struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)&st->addr;
struct sockaddr_in6 *mask = (struct sockaddr_in6 *)&st->netmask;
sin6->sin6_family = AF_INET6;
memcpy(&sin6->sin6_addr, RTA_DATA(ra), 16);
/* A link-local address is not routable without its scope id. */
if (IN6_IS_ADDR_LINKLOCAL(&sin6->sin6_addr) ||
IN6_IS_ADDR_MC_LINKLOCAL(&sin6->sin6_addr))
sin6->sin6_scope_id = ifa->ifa_index;
mask->sin6_family = AF_INET6;
rd_fill_mask((unsigned char *)&mask->sin6_addr, 16, ifa->ifa_prefixlen);
}
if (c->tail != NULL)
c->tail->ifa_next = &st->ifa;
else
c->head = &st->ifa;
c->tail = &st->ifa;
return 0;
}
void freeifaddrs(struct ifaddrs *ifa)
{
while (ifa != NULL) {
struct ifaddrs *next = ifa->ifa_next;
free(ifa);
ifa = next;
}
}
int getifaddrs(struct ifaddrs **ifap)
{
struct rd_link_table links;
struct rd_addr_ctx ctx;
int fd;
int saved;
if (ifap == NULL) {
errno = EINVAL;
return -1;
}
*ifap = NULL;
memset(&links, 0, sizeof(links));
memset(&ctx, 0, sizeof(ctx));
ctx.links = &links;
fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE);
if (fd < 0)
return -1;
if (rd_nl_dump(fd, RTM_GETLINK, RTM_NEWLINK, 1, rd_link_cb, &links) != 0)
goto fail;
if (rd_nl_dump(fd, RTM_GETADDR, RTM_NEWADDR, 2, rd_addr_cb, &ctx) != 0)
goto fail;
close(fd);
free(links.items);
*ifap = ctx.head;
return 0;
fail:
saved = errno;
close(fd);
free(links.items);
freeifaddrs(ctx.head);
errno = saved;
return -1;
}

View File

@@ -174,7 +174,12 @@ async fn connect_and_login(
received = true;
interface.update_received(true);
}
let msg_in = Message::parse_from_bytes(&bytes)?;
let msg_in = match Message::parse_from_bytes(&bytes) {
Ok(msg) => msg,
Err(err) => {
return Err(err.into());
}
};
match msg_in.union {
Some(message::Union::Hash(hash)) => {
if !interface.handle_hash(password, hash, &mut stream).await {

View File

@@ -1,4 +1,6 @@
use std::{
collections::{hash_map::RandomState, HashMap, VecDeque},
hash::BuildHasher,
net::SocketAddr,
sync::{
atomic::{AtomicBool, Ordering},
@@ -21,8 +23,13 @@ use hbb_common::{
rendezvous_proto::*,
sleep,
socket_client::{self, connect_tcp, is_ipv4, new_direct_udp_for, new_udp_for},
tokio::{self, select, sync::Mutex, time::interval},
tokio::{
self, select,
sync::{mpsc, Mutex},
time::interval,
},
udp::FramedSocket,
webrtc::WebRTCStream,
AddrMangle, IntoTargetAddr, ResultType, Stream, TargetAddr,
};
@@ -47,7 +54,66 @@ lazy_static::lazy_static! {
static ref SOLVING_PK_MISMATCH: Mutex<String> = Default::default();
static ref LAST_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now()));
static ref LAST_RELAY_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now()));
static ref WEBRTC_ICE_TXS: Mutex<HashMap<String, IceRoute>> = Default::default();
static ref ICE_DIGEST_STATE: RandomState = Default::default();
}
/// Remote ICE candidates buffered per session while the answerer applies them. Same depth as the
/// controller's own buffer (`Client::MAX_PENDING_WEBRTC_ICE`), though that one evicts its oldest
/// where a full channel here refuses the newest.
const MAX_PENDING_REMOTE_ICE: usize = 64;
/// Queued candidates remembered so the controller's re-send is skipped instead of taking a slot
/// of its own. Far more than an honest peer gathers, at eight bytes each.
const ICE_DEDUP_WINDOW: usize = 256;
// The rendezvous ICE route is reachable without a prior punch and the peer decides how many
// candidates it sends, so these sites would let someone else set how much this machine writes to
// its log file. One line a minute each, carrying the suppressed count.
const ICE_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
static UNKNOWN_ICE_SESSION_LOG: hbb_common::log_throttle::LogThrottle =
hbb_common::log_throttle::LogThrottle::new(ICE_LOG_INTERVAL);
static REJECTED_REMOTE_ICE_LOG: hbb_common::log_throttle::LogThrottle =
hbb_common::log_throttle::LogThrottle::new(ICE_LOG_INTERVAL);
static FULL_ICE_QUEUE_LOG: hbb_common::log_throttle::LogThrottle =
hbb_common::log_throttle::LogThrottle::new(ICE_LOG_INTERVAL);
struct IceRoute {
tx: mpsc::Sender<String>,
recent: VecDeque<u64>,
}
impl IceRoute {
fn new(tx: mpsc::Sender<String>) -> Self {
Self {
tx,
recent: VecDeque::new(),
}
}
/// Keeps `queue` the only way onto the channel, so nothing reaches it unrecorded.
fn is_same_channel(&self, other: &mpsc::Sender<String>) -> bool {
self.tx.same_channel(other)
}
/// Skip the controller's re-send of a candidate already queued: the ICE agent that dedups
/// repeats is downstream of this queue, so the copy would spend a slot of its own.
/// False means the candidate was dropped.
fn queue(&mut self, candidate: String) -> bool {
let digest = ICE_DIGEST_STATE.hash_one(candidate.as_str());
if self.recent.contains(&digest) {
// Only honest about the drop if the route is still alive to have taken it.
return !self.tx.is_closed();
}
// Recorded once queued, never before: a refused candidate stays repairable by the re-send.
if self.tx.try_send(candidate).is_err() {
return false;
}
if self.recent.len() >= ICE_DEDUP_WINDOW {
self.recent.pop_front();
}
self.recent.push_back(digest);
true
}
}
static SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false);
static SENT_REGISTER_PK: AtomicBool = AtomicBool::new(false);
@@ -399,6 +465,30 @@ impl RendezvousMediator {
allow_err!(rz.handle_intranet(fla, server).await);
});
}
Some(rendezvous_message::Union::IceCandidate(ice)) => {
let queued = {
let mut txs = WEBRTC_ICE_TXS.lock().await;
txs.get_mut(&ice.session_key)
.map(|route| route.queue(ice.candidate))
};
match queued {
Some(false) => {
if let Some(n) = FULL_ICE_QUEUE_LOG.due() {
log::debug!("dropped {} ICE candidate(s): queue full or closed", n);
}
}
None => {
if let Some(n) = UNKNOWN_ICE_SESSION_LOG.due() {
log::debug!(
"dropped {} ICE candidate(s) for unknown WebRTC session key, last: {}",
n,
ice.session_key
);
}
}
_ => {}
}
}
Some(rendezvous_message::Union::ConfigureUpdate(cu)) => {
let v0 = Config::get_rendezvous_servers();
Config::set_option(
@@ -508,6 +598,7 @@ impl RendezvousMediator {
rr.secure,
false,
Default::default(),
String::new(),
meta,
)
.await
@@ -522,6 +613,7 @@ impl RendezvousMediator {
secure: bool,
initiate: bool,
socket_addr_v6: bytes::Bytes,
webrtc_sdp_answer: String,
meta: ConnectionMeta,
) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&socket_addr);
@@ -540,6 +632,7 @@ impl RendezvousMediator {
socket_addr: socket_addr.into(),
version: crate::VERSION.to_owned(),
socket_addr_v6,
webrtc_sdp_answer,
..Default::default()
};
if initiate {
@@ -606,6 +699,7 @@ impl RendezvousMediator {
true,
true,
socket_addr_v6,
String::new(),
meta,
)
.await
@@ -642,6 +736,163 @@ impl RendezvousMediator {
Ok(())
}
/// Build the WebRTC answerer for a punch-hole offer and return the SDP answer that rides in
/// the punch reply (PunchHoleSent / RelayResponse).
///
/// Awaited inline on the punch-reply path, which only holds because everything here is local
/// (pc + keygen + SDP; trickle means the answer carries no candidates). Keep network I/O out
/// — connection setup belongs in the detached task below.
async fn spawn_webrtc_answerer(
&self,
ph: &PunchHole,
relay_only_ice: bool,
server: ServerPtr,
peer_addr: SocketAddr,
meta: ConnectionMeta,
) -> ResultType<String> {
let mut stream =
WebRTCStream::new(&ph.webrtc_sdp_offer, relay_only_ice, CONNECT_TIMEOUT).await?;
let answer = stream.local_endpoint().to_owned();
let session_key = stream.session_key().to_owned();
let return_route = ph.socket_addr.clone();
// A duplicate PunchHole (the offerer re-sends the same request across punch attempts)
// resolves to the SESSIONS-cached stream. `take_local_ice_rx` yields the receiver
// exactly once per stream instance, so `None` here means an answerer was already
// spawned for this offer: return the (identical) cached answer without spawning a
// second connect task. Otherwise two `create_tcp_connection` tasks would detach and
// read the same data channel, interleaving the handshake and corrupting the session.
let Some(mut local_ice_rx) = stream.take_local_ice_rx() else {
return Ok(answer);
};
// Bounded: how many candidates arrive is the sender's choice, while draining one costs a
// JSON parse and the ICE agent's lock, so an unbounded queue lets whoever can reach this
// session's route grow it without limit inside a long-lived service process. A full queue
// drops the newest candidate, and the controller re-sends it once — the digests beside the
// sender are what keep that re-send from spending a slot of its own.
let (remote_ice_tx, mut remote_ice_rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
let own_ice_tx = remote_ice_tx.clone();
WEBRTC_ICE_TXS
.lock()
.await
.insert(session_key.clone(), IceRoute::new(remote_ice_tx));
let stream_for_remote_ice = stream.clone();
tokio::spawn(async move {
while let Some(candidate) = remote_ice_rx.recv().await {
if let Err(err) = stream_for_remote_ice.add_remote_ice_candidate(&candidate).await
{
if let Some(n) = REJECTED_REMOTE_ICE_LOG.due() {
log::warn!(
"failed to add {} remote WebRTC ICE candidate(s), last: {}",
n,
err
);
}
}
}
});
{
let host = self.host.clone();
let socket_addr = return_route.clone();
let session_key_for_ice = session_key.clone();
tokio::spawn(async move {
// Candidates ride a dedicated TCP connection to the rendezvous server, like
// the answer, NOT the mediator channel: that channel is UDP in the default
// setup, and target deployments front hbbs with websocket/TCP only, where
// its UDP port is unreachable. The server keeps candidate-carrying TCP
// connections open, so one lazily-opened connection serves the whole
// trickle, and TCP reliability replaces the old 400ms duplicate re-send
// (the controller keeps its own re-send for the server->peer UDP downlink).
let mut conn = None;
while let Some(candidate) = local_ice_rx.recv().await {
let mut msg = Message::new();
msg.set_ice_candidate(IceCandidate {
socket_addr: socket_addr.clone(),
session_key: session_key_for_ice.clone(),
candidate,
..Default::default()
});
// One reconnect attempt per candidate: the first send after an hbbs
// restart or an idle-killed connection fails on the stale stream.
for _ in 0..2 {
if conn.is_none() {
match connect_tcp(&*host, CONNECT_TIMEOUT).await {
Ok(s) => conn = Some(s),
Err(err) => {
log::warn!(
"failed to connect for WebRTC ICE candidate: {}",
err
);
break;
}
}
}
if let Some(s) = conn.as_mut() {
match s.send(&msg).await {
Ok(()) => break,
Err(err) => {
log::debug!(
"WebRTC ICE candidate send failed, reconnecting: {}",
err
);
conn = None;
}
}
}
}
}
});
}
let session_key_for_cleanup = session_key.clone();
tokio::spawn(async move {
let result = stream.wait_connected(CONNECT_TIMEOUT).await;
// Only evict our own route. The key is the offer's DTLS fingerprint, identical across
// the controller's punch retries, so a retry that built a fresh answerer has already
// replaced this entry — removing it blindly would delete the live session's sender and
// leave it receiving no candidates at all.
{
let mut txs = WEBRTC_ICE_TXS.lock().await;
if txs
.get(&session_key_for_cleanup)
.is_some_and(|route| route.is_same_channel(&own_ice_tx))
{
txs.remove(&session_key_for_cleanup);
}
}
if let Err(err) = result {
log::warn!("webrtc wait_connected failed: {}", err);
// Release the pc now rather than waiting for the ICE agent to time out into a
// terminal state (~30s); this also drops the SESSIONS entry promptly.
stream.close().await;
return;
}
// create_tcp_connection takes ownership of the stream; keep a handle to close the pc
// once the session returns. It runs the whole session and returns Ok on normal end,
// Err on setup failure — either way the pc must be closed, else it lingers forever in
// SESSIONS (its state handler only fires on a terminal ICE state, which a cleanly
// closed session may never reach) leaking the pc, channels, and socket fds.
let stream_for_cleanup = stream.clone();
if let Err(err) = crate::server::create_tcp_connection(
server,
Stream::WebRTC(stream),
peer_addr,
true,
meta,
)
.await
{
log::warn!("failed to create WebRTC server connection: {}", err);
}
stream_for_cleanup.close().await;
});
Ok(answer)
}
async fn handle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> {
let mut peer_addr = AddrMangle::decode(&ph.socket_addr);
let last = *LAST_MSG.lock().await;
@@ -651,12 +902,41 @@ impl RendezvousMediator {
return Ok(());
}
let peer_addr_v6 = hbb_common::AddrMangle::decode(&ph.socket_addr_v6);
let relay = use_ws() || Config::is_proxy() || ph.force_relay;
let local_proxy = use_ws() || Config::is_proxy();
let relay = local_proxy || ph.force_relay;
let mut socket_addr_v6 = Default::default();
let meta = connection_meta(
ph.control_permissions.into_option(),
ph.controlled_context.into_option(),
ph.control_permissions.clone().into_option(),
ph.controlled_context.clone().into_option(),
);
// The controller's force_relay alone does not say whether ICE must be Relay-only; its
// offer envelope does. `ice_policy: "all"` means the relay was forced by the transport
// (ws), so answer with full ICE and let a direct pair form.
let webrtc_relay_only =
ph.force_relay && !WebRTCStream::endpoint_declares_all_ice(&ph.webrtc_sdp_offer);
// No enable-webrtc check here: it is LocalConfig, which the UI process writes and never
// syncs over IPC, so this (server) process would read the private-server default of "N"
// and refuse to answer in exactly the self-hosted deployments the transport is for.
// A proxy still rules it out — ICE would bypass it and leak the real IP.
let webrtc_viable = !ph.webrtc_sdp_offer.is_empty()
&& !Config::is_proxy()
&& (!webrtc_relay_only || WebRTCStream::has_turn_server());
let webrtc_sdp_answer = if webrtc_viable {
self.spawn_webrtc_answerer(
&ph,
webrtc_relay_only,
server.clone(),
peer_addr,
meta.clone(),
)
.await
.unwrap_or_else(|err| {
log::warn!("failed to create WebRTC answer: {}", err);
String::new()
})
} else {
String::new()
};
if peer_addr_v6.port() > 0 && !relay {
socket_addr_v6 =
start_ipv6(peer_addr_v6, peer_addr, server.clone(), meta.clone()).await;
@@ -678,6 +958,7 @@ impl RendezvousMediator {
true,
true,
socket_addr_v6.clone(),
webrtc_sdp_answer.clone(),
meta,
)
.await;
@@ -691,6 +972,7 @@ impl RendezvousMediator {
nat_type: nat_type.into(),
version: crate::VERSION.to_owned(),
socket_addr_v6,
webrtc_sdp_answer,
..Default::default()
};
if ph.udp_port > 0 {
@@ -699,12 +981,25 @@ impl RendezvousMediator {
.await?;
return Ok(());
}
if !ph.webrtc_sdp_offer.is_empty() {
// Return the answer over its own short-lived TCP connection rather than the mediator
// channel: that channel is UDP by default, and hbbs applies UDP-punch semantics
// (source-address observation) to a PunchHoleSent that arrives on it. No TCP punch
// is made — the controller keeps its request socket for trickled ICE.
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(msg_punch);
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
socket.send(&msg_out).await?;
return Ok(());
}
log::debug!("Punch tcp hole to {:?}", peer_addr);
let mut socket = {
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// key important here for punch hole to tell my gateway incoming peer is safe.
// it can not be async here, because local_addr can not be reused, we must close the connection before use it again.
// Awaited rather than spawned so the mapping exists before `PunchHoleSent` goes out;
// `local_addr` itself is shared, not exclusive - every socket here binds it with the
// reuse flags `new_socket` sets.
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
socket
};
@@ -712,7 +1007,10 @@ impl RendezvousMediator {
msg_out.set_punch_hole_sent(msg_punch);
let bytes = msg_out.write_to_bytes()?;
socket.send_raw(bytes).await?;
crate::accept_connection(server.clone(), socket, peer_addr, true, meta).await;
let local_addr = socket.local_addr();
// The listener inside takes this address over, so the mediator's socket goes first.
drop(socket);
punch_tcp_until_connected(server, peer_addr, local_addr, meta).await;
Ok(())
}
@@ -951,11 +1249,11 @@ async fn udp_nat_listen(
let socket_cloned = socket.clone();
let func = async {
socket.connect(peer_addr).await?;
let res = crate::punch_udp(socket.clone(), true).await?;
let init_packet = crate::punch_udp(socket.clone(), true).await?;
let stream = crate::kcp_stream::KcpStream::accept(
socket,
Duration::from_millis(CONNECT_TIMEOUT as _),
res,
init_packet,
)
.await?;
crate::server::create_tcp_connection(server, stream.1, peer_addr_v4, true, meta).await?;
@@ -971,6 +1269,194 @@ async fn udp_nat_listen(
Ok(())
}
/// Where the repeats start, and the factor they slow by. The controller's SYN arrives once, at an
/// instant we are never told, inside a window we are not told either: `Client::connect` sizes its
/// dial only after our PunchHoleSent, from its own rendezvous time and the direct failures it has
/// recorded for us - `CONNECT_TIMEOUT` between two known-asymmetric NATs that never failed, as
/// little as a second once one has. So the repeats cover our own ceiling instead, `CONNECT_TIMEOUT`,
/// which is as long as the accept below has always been willing to take a connection, and back
/// off across it: dense at the start, where every window begins and the short ones end, sparse
/// afterwards, which is `punch_udp`'s shape for the same reason.
const PUNCH_INTERVAL: f32 = 0.15;
const PUNCH_BACKOFF: f32 = 1.5;
const PUNCH_MAX_INTERVAL: f32 = 2.0;
/// How long a punch in flight may run past the deadline, and the only timer it runs on. A punch
/// is cancel-safe while it is still in SYN_SENT and not once the controller's SYN has crossed it:
/// the socket is then half way through a handshake, and dropping it there cuts the connection the
/// controller is opening - which its `connect` has already returned, so that attempt fails
/// outright rather than falling back to relay. A timer cannot tell the two states apart, so no
/// punch is cut on a schedule of its own, and none needs to be. A gateway that answers with RST
/// fails the connect at once, and the loop punches again. One that drops the SYN in silence
/// leaves the socket in SYN_SENT, where it holds the mapping open and the kernel re-sends the
/// SYN, and any SYN of the controller's that arrives crosses it - a second punch has nothing to
/// add. That leaves the deadline, and this much past it lets a crossing begun just before it
/// complete; Windows gives a SYN up at about 21s anyway.
const PUNCH_GRACE: u64 = 3000;
/// The punch above leaves before hbbs has told the controller where to dial, so it is never in
/// flight at the same time as the controller's SYN: it opens our NAT, meets nothing, and a gateway
/// that answers it with RST takes the mapping down with it - leaving the listener below waiting on
/// a hole that no longer exists. Punching again across the window in which the controller dials
/// rebuilds it, and once the controller sits in SYN_SENT one of those punches meets its SYN and
/// completes as a simultaneous open: a second way in, which a single punch never had.
async fn punch_tcp_until_connected(
server: ServerPtr,
peer_addr: SocketAddr,
local_addr: SocketAddr,
meta: ConnectionMeta,
) {
use hbb_common::tcp::new_listener;
// Shadows the module's `std::time::Instant`: the deadline is held against tokio's sleeps and
// timeouts, so it runs on their clock.
use hbb_common::tokio::time::Instant;
// Not fatal on its own - the punch below can still meet the controller's SYN without it, and
// that half is the one a listener the OS refused to bind could not have covered anyway.
let listener = match new_listener(local_addr, true).await {
Ok(listener) => {
log::info!("Server listening on: {local_addr}");
Some(listener)
}
Err(err) => {
log::warn!("Failed to listen on {local_addr} after punching: {err}");
None
}
};
// Bounds both halves: the punch keeps the mapping open only while the accept is still
// willing to take a connection through it.
let until = Instant::now() + Duration::from_millis(CONNECT_TIMEOUT);
let punch = punch_until(until, peer_addr, |ms| {
socket_client::connect_tcp_local(peer_addr, Some(local_addr), ms)
});
let Some(listener) = listener else {
if let Some(stream) = punch.await {
serve_punched(server, stream, peer_addr, meta).await;
}
return;
};
// Accepting in a loop, not once: a transient `accept` error must not spend the whole window
// the controller still has to arrive in.
let accept = async {
loop {
let left = until.saturating_duration_since(Instant::now()).as_millis() as u64;
if left == 0 {
break;
}
match hbb_common::timeout(left, listener.accept()).await {
// Not filtered by address, as `accept_connection` never did: hbbs saw the
// controller through one mapping and a NAT that pools its external addresses may
// dial us from another, and what keeps `meta`'s control permissions from a second
// peer is the handshake, plus that exactly one connection is ever served.
Ok(Ok(accepted)) => return Some(accepted),
Ok(Err(err)) => {
log::warn!("Failed to accept from {peer_addr}: {err}");
// One that persists - EMFILE, say - would otherwise spin here for the window.
sleep(1.).await;
}
Err(_) => break,
}
}
log::info!("Nothing connected to the hole punched to {peer_addr}");
None
};
// Only the accept races the punch. Racing `accept_connection` instead would race the whole
// session it goes on to run, so a punch landing mid-session would tear that session down.
//
// Whichever arrives first is the one connection this request produces. Serving the loser too
// would give a second peer the control permissions hbbs granted for this one controller, and
// no test on the connection itself can tell the two apart before `create_tcp_connection` has
// spoken to it - so the invariant is kept here, by there being no second serve.
let punched = select! {
// Both ready at once is two connections, not one seen twice - a crossing carries the
// punch's four-tuple, which the listener never matches - and the punch is the one kept:
// it is known to have met something at the address hbbs gave, where the accept takes
// any address, and dropping it would reset the connection the controller is opening.
biased;
Some(stream) = punch => stream,
Some((stream, addr)) = accept => {
return accept_punched_connection(server, stream, addr, meta).await;
}
else => return,
};
serve_punched(server, punched, peer_addr, meta).await;
}
/// The repeats of `punch_tcp_until_connected`, over any punch rather than `connect_tcp_local`
/// alone, so that a test can run the schedule against a paused clock - which no socket can be.
async fn punch_until<T, F, Fut>(
until: tokio::time::Instant,
peer_addr: SocketAddr,
mut punch: F,
) -> Option<T>
where
F: FnMut(u64) -> Fut,
Fut: std::future::Future<Output = ResultType<T>>,
{
use hbb_common::tokio::time::Instant;
let mut interval = PUNCH_INTERVAL;
let mut round = 0;
loop {
// The deadline decides whether another punch starts, never how long one already in
// flight may take: that one runs to PUNCH_GRACE past it.
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() {
log::debug!("None of {round} punches to {peer_addr} was met");
return None;
}
// Cut at the deadline rather than slept out past it, so the window ends on a punch and
// not on a gap of up to PUNCH_MAX_INTERVAL: the controller's window opened after ours,
// on the PunchHoleSent hbbs relayed, so one as long as ours is still open through our tail.
tokio::time::sleep(Duration::from_secs_f32(interval).min(left)).await;
interval = (interval * PUNCH_BACKOFF).min(PUNCH_MAX_INTERVAL);
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64 + PUNCH_GRACE;
match punch(ms).await {
// The controller's SYN crossed this punch, so the stream is the connection it
// dialed, not a spare one: dropping it would reset that connection.
Ok(stream) => return Some(stream),
// Not logged one by one, but the count says which gateway it was: RST fails a
// punch at once and fits a dozen into the window, a silent drop holds the one
// punch for the whole of it. `connect_tcp_local` keeps no errno anyway.
Err(_) => round += 1,
}
}
}
async fn serve_punched(
server: ServerPtr,
stream: Stream,
peer_addr: SocketAddr,
meta: ConnectionMeta,
) {
log::info!("Punched tcp hole to {peer_addr}, connected on the punch itself");
if let Err(err) =
crate::server::create_tcp_connection(server, stream, peer_addr, true, meta).await
{
log::warn!("Failed to serve the connection punched to {peer_addr}: {err}");
}
}
/// The accept half of `accept_connection`, kept here because only the accept may race the punch.
async fn accept_punched_connection(
server: ServerPtr,
stream: tokio::net::TcpStream,
addr: SocketAddr,
meta: ConnectionMeta,
) {
use crate::server::create_tcp_connection;
stream.set_nodelay(true).ok();
match stream.local_addr() {
Ok(stream_addr) => {
let stream = Stream::from(stream, stream_addr);
if let Err(err) = create_tcp_connection(server, stream, addr, true, meta).await {
log::warn!("Failed to serve the connection from {addr}: {err}");
}
}
Err(err) => log::warn!("Failed to read the address accepted from {addr}: {err}"),
}
}
// When config is not yet synced from root, register_pk may have already been sent with a new generated pk.
// After config sync completes, the pk may change. This struct detects pk changes and triggers
// a re-registration by setting key_confirmed to false.
@@ -995,3 +1481,255 @@ impl Drop for CheckIfResendPk {
}
}
}
#[cfg(test)]
mod tests {
use super::{mpsc, socket_client, tokio, IceRoute, ICE_DEDUP_WINDOW, MAX_PENDING_REMOTE_ICE};
use hbb_common::tcp::new_listener;
use std::net::SocketAddr;
// A SOCKS proxy makes `connect_tcp_local` dial the proxy and ignore the local address, so
// nothing these two assert can hold. Read once, from the same global config production reads.
fn proxied() -> bool {
hbb_common::config::Config::get_socks().is_some()
}
/// Both held while their addresses are read, so the pair cannot be the same port - which
/// `SO_REUSEPORT` would let bind twice rather than refuse, leaving the tests degenerate.
async fn free_loopback_pair() -> (SocketAddr, SocketAddr) {
let (a, b) = (
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(),
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(),
);
(a.local_addr().unwrap(), b.local_addr().unwrap())
}
fn queue(route: &mut IceRoute, candidate: &str) -> bool {
route.queue(candidate.to_owned())
}
#[test]
fn the_re_sent_copy_does_not_spend_a_queue_slot() {
// Two slots, three sends: without the dedup the re-send takes the second and "relay",
// the one that traverses NAT, is the one refused.
let (tx, mut rx) = mpsc::channel::<String>(2);
let mut route = IceRoute::new(tx);
for _ in 0..2 {
assert!(queue(&mut route, "host"));
}
assert!(queue(&mut route, "relay"));
let mut queued = Vec::new();
while let Ok(candidate) = rx.try_recv() {
queued.push(candidate);
}
assert_eq!(queued, vec!["host".to_owned(), "relay".to_owned()]);
}
#[test]
fn a_candidate_the_full_queue_refused_is_not_remembered() {
let (tx, mut rx) = mpsc::channel::<String>(1);
let mut route = IceRoute::new(tx);
assert!(queue(&mut route, "host"));
assert!(!queue(&mut route, "relay"));
// The re-send is the only repair for a refused candidate; remembering it would swallow it.
assert_eq!(rx.try_recv().ok(), Some("host".to_owned()));
assert!(queue(&mut route, "relay"));
assert_eq!(rx.try_recv().ok(), Some("relay".to_owned()));
}
#[test]
fn a_re_send_is_skipped_while_the_original_is_still_queued() {
let (tx, mut rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
let mut route = IceRoute::new(tx);
for i in 0..MAX_PENDING_REMOTE_ICE {
assert!(queue(&mut route, &format!("candidate-{}", i)));
}
assert!(queue(&mut route, "candidate-0"));
let mut queued = 0;
while rx.try_recv().is_ok() {
queued += 1;
}
assert_eq!(queued, MAX_PENDING_REMOTE_ICE);
}
#[test]
fn the_window_forgets_in_arrival_order() {
let (tx, mut rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
let mut route = IceRoute::new(tx);
for i in 0..=ICE_DEDUP_WINDOW {
assert!(queue(&mut route, &format!("candidate-{}", i)));
assert!(rx.try_recv().is_ok());
}
// The oldest digest made room for the newest, so its re-send is admitted again.
assert!(queue(&mut route, "candidate-0"));
assert!(rx.try_recv().is_ok());
// A recent one is still skipped.
let recent = format!("candidate-{}", ICE_DEDUP_WINDOW);
assert!(queue(&mut route, &recent));
assert!(rx.try_recv().is_err());
}
// The second way in that the repeat punch opens: a punch reaching a peer already in SYN_SENT
// is answered by that socket rather than reset, and the two ends come up on one connection.
// A punch that misses the crossing is reset outright here, loopback having no NAT to absorb
// it and no round trip to hide behind - so a single punch lands only by luck, and repeating
// is what makes it land at all. That is the premise of the repeat, asserted directly. A round
// that misses costs one loopback RST, so rounds are cheap and there are many.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_punch_that_meets_the_peers_syn_connects_both_ends() {
// The crossing needs both connects genuinely in flight at once. Loopback answers a SYN to
// a port nobody is listening on with an instant RST, so on one CPU the first connect runs
// to completion before the second is scheduled and no round can ever cross - a property of
// the box, which this test cannot tell apart from a broken punch.
if proxied() || std::thread::available_parallelism().map_or(true, |cpus| cpus.get() < 2) {
return;
}
for _ in 0..256 {
let (a, b) = free_loopback_pair().await;
// Held for the whole crossing, because production always has one here and the design
// rests on which of the two the kernel hands the connection to: the punch and the
// peer's SYN share a four-tuple exactly, the listener only matches the address, and
// the punch has to win that or every crossing would be swallowed as a plain accept.
let listener = new_listener(a, true).await.unwrap();
let to_b = tokio::spawn(socket_client::connect_tcp_local(b, Some(a), 3000));
let to_a = tokio::spawn(socket_client::connect_tcp_local(a, Some(b), 3000));
let (at_a, at_b) = tokio::join!(to_b, to_a);
let (Ok(Ok(mut at_a)), Ok(Ok(mut at_b))) = (at_a, at_b) else {
continue;
};
at_a.send_bytes(bytes::Bytes::from_static(b"punch"))
.await
.unwrap();
let got = at_b.next_timeout(3000).await.unwrap().unwrap();
assert_eq!(&got[..], b"punch", "both ends must share one connection");
assert!(
hbb_common::timeout(200, listener.accept()).await.is_err(),
"the crossing must reach the punch, not be accepted as an inbound connection"
);
return;
}
panic!("no punch met the peer's SYN in 256 rounds on a machine that can cross them");
}
// The punch binds the address the listener already holds, so it has to go through the same
// `connect_tcp_local` production uses - a punch built by hand here would still pass if
// `new_socket` ever stopped setting the reuse flags, while every real punch failed to bind.
// The peer's view of the source port is what proves the bind took: a fallback to an ephemeral
// one would connect just as happily.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_punch_binds_the_address_the_listener_holds() {
if proxied() {
return;
}
// `free_loopback_pair` hands back ports it no longer holds, so another process can take
// one in between; retry rather than fail for something the punch had no part in.
for _ in 0..8 {
let (local, peer_addr) = free_loopback_pair().await;
let (Ok(listener), Ok(peer)) = (
new_listener(local, true).await,
new_listener(peer_addr, true).await,
) else {
continue;
};
let punch = tokio::spawn(socket_client::connect_tcp_local(
peer_addr,
Some(local),
1500,
));
let (_peer_side, seen_as) = hbb_common::timeout(3000, peer.accept())
.await
.expect("the punch must reach the peer")
.unwrap();
assert_eq!(
seen_as.port(),
local.port(),
"the punch must leave from the address the listener holds, not an ephemeral one"
);
// Held, not asserted and dropped: the coexistence below is only exercised while this
// socket is still on the address, which is the state production spends its window in.
let _punched = punch.await.unwrap().expect("the punch must connect");
let dialed = tokio::spawn(tokio::net::TcpStream::connect(local));
let accepted = hbb_common::timeout(3000, listener.accept()).await;
assert!(
matches!(accepted, Ok(Ok(_))),
"the listener must still take connections while a punch shares its address: {accepted:?}"
);
assert!(dialed.await.unwrap().is_ok());
return;
}
panic!("could not hold two free loopback addresses in 8 tries");
}
// The schedule on its own, against a paused clock: the window is CONNECT_TIMEOUT long, and
// what these pin is where inside it the punches fall, which no socket could show.
#[tokio::test(start_paused = true)]
async fn the_punches_end_on_one_at_the_deadline() {
use super::{punch_until, PUNCH_GRACE, PUNCH_INTERVAL, PUNCH_MAX_INTERVAL};
use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT};
use std::time::Duration;
use tokio::time::Instant;
let peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
let start = Instant::now();
let until = start + Duration::from_millis(CONNECT_TIMEOUT);
let mut punches = Vec::new();
// A gateway that answers with RST: every punch fails the moment it is made.
let met = punch_until::<(), _, _>(until, peer, |ms| {
punches.push((Instant::now(), ms));
async { Err(anyhow!("RST")) }
})
.await;
assert!(met.is_none());
assert_eq!(
Instant::now(),
until,
"must return the moment the window closes, not a backoff later"
);
// Tokio rounds every sleep up to the next millisecond.
let slack = Duration::from_millis(1);
assert!(punches[0].0 - start <= Duration::from_secs_f32(PUNCH_INTERVAL) + slack);
for pair in punches.windows(2) {
assert!(
pair[1].0 - pair[0].0 <= Duration::from_secs_f32(PUNCH_MAX_INTERVAL) + slack,
"no gap in the window may exceed the backoff ceiling: {pair:?}"
);
}
assert_eq!(
*punches.last().unwrap(),
(until, PUNCH_GRACE),
"the window must end on a punch, given the whole grace"
);
}
#[tokio::test(start_paused = true)]
async fn a_punch_in_flight_runs_the_grace_past_the_deadline_and_no_further() {
use super::{punch_until, PUNCH_GRACE};
use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT};
use std::time::Duration;
use tokio::time::Instant;
let peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
let until = Instant::now() + Duration::from_millis(CONNECT_TIMEOUT);
let mut punches = 0;
// A gateway that drops the SYN in silence: the punch sits in SYN_SENT for all it is given.
let met = punch_until::<(), _, _>(until, peer, |ms| {
punches += 1;
async move {
tokio::time::sleep(Duration::from_millis(ms)).await;
Err(anyhow!("timed out"))
}
})
.await;
assert!(met.is_none());
assert_eq!(
punches, 1,
"a punch held in SYN_SENT is the only one the window needs"
);
assert_eq!(
Instant::now(),
until + Duration::from_millis(PUNCH_GRACE),
"must return when the grace runs out, not a backoff later"
);
}
}

View File

@@ -211,11 +211,21 @@ pub async fn create_tcp_connection(
let sk = sign::SecretKey(sk_);
let mut msg_out = Message::new();
let (our_pk_b, our_sk_b) = box_::gen_keypair();
// On a WebRTC transport, bind our DTLS certificate fingerprint to our signed identity so
// the controller can verify the DTLS channel it negotiated actually terminates at us
// (not a rendezvous/relay that swapped the SDP fingerprint). Empty on other transports.
// Fail immediately on WebRTC if the local fingerprint is unavailable: signing "" would
// only make the client fail-closed after a wasted round-trip.
let dtls_fingerprint = stream.dtls_fingerprint(true).await.unwrap_or_default();
if stream.is_webrtc() && dtls_fingerprint.is_empty() {
bail!("WebRTC local DTLS fingerprint unavailable");
}
msg_out.set_signed_id(SignedId {
id: sign::sign(
&IdPk {
id: Config::get_id(),
pk: Bytes::from(our_pk_b.0.to_vec()),
dtls_fingerprint,
..Default::default()
}
.write_to_bytes()

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,

View File

@@ -1294,7 +1294,13 @@ impl<T: InvokeUiSession> Session<T> {
// override only if true
if true == force_relay {
self.lc.write().unwrap().force_relay = true;
let mut lc = self.lc.write().unwrap();
lc.force_relay = true;
// An explicit retry-via-relay is a decision about this peer, not transport
// necessity: Relay-only ICE for this round like any force-always-relay session,
// and it is the one kind of relay that belongs in the peer's saved config.
lc.policy_relay = true;
lc.peer_relay = true;
}
self.lc.write().unwrap().peer_info = None;
self.reconnect_count.fetch_add(1, Ordering::SeqCst);