Compare commits

..

160 Commits

Author SHA1 Message Date
changshenhan
91c9fccbb0 chore(deps): security bumps in Cargo.lock (RUSTSEC-2026 fixes) (#16143)
Co-authored-by: changshenhan <217217832+changshenhan@users.noreply.github.com>
2026-09-10 17:06:24 +08:00
fufesou
c4221469d8 Fix/audio stream continuity (#16095)
* fix(audio): add streaming resampler

* fix(audio): preserve stream resampling state

* fix(audio): keep playback callback nonblocking

* fix(audio): decouple capture conversion from dasp

* fix(audio): support stateful samplerate backend

* refactor(audio): isolate stream callback state

* refactor(audio): group capture output options

* fix(audio): clear stale playback state after startup failure

Reset non-Linux playback state when stream startup fails to prevent
new-format audio from using the previous stream or resampler.

Add regression tests for failed format changes and successful playback.

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

* fix(audio): honor capture resampler selection and reuse buffers

Use the selected resampling backend for fixed-frame capture.
Convert samples directly into the input queue and
reuse the PCM frame buffer.

Add tests for anti-aliasing, thread transfer, and
partial-frame draining.

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

* refact: reduce diffs

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

* test(audio): check resampler output count and passband energy

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

* fix(audio): reset incompatible Linux playback state on
  startup failure

Preserve compatible output streams when replacement
  startup fails.
Clear state when no compatible stream exists and cover
  both paths in tests.

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

* perf(audio): reuse PCM buffers in the capture pipeline

- Reuse capture framing, resampling, and channel conversion buffers
- Deliver borrowed packets and write Sinc output into reusable storage
- Add allocation and output-equivalence regression tests

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

* fix(audio): smooth buffer discard discontinuities

Signal receiver PCM discards and fade from the current playback output when the callback reaches the new timeline.

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

* fix(audio): add missing Cargo.toml

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

* perf(audio): move capture encoding off the CPAL callback

Move Opus encoding and service delivery to a dedicated worker.
Use a preallocated bounded PCM queue with explicit loss reporting.
Add tests for callback allocations and queue saturation.

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

* fix(audio): smooth capture gaps and report losses during backlog

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

* feat(audio): report capture queue high-water mark

Track peak queued PCM packets and log the approximate
queued audio duration alongside capture loss statistics.

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

* refact(audio): reduce diffs

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

* fix(audio): avoid blocking capture on encoder queue contention

Use preallocated queues with try_lock in the capture callback.
Count and drop the current packet on contention, preserving
drop-oldest behavior on overflow.

Add regressions for paused workers, buffer reuse, and sequence wrap.

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

* fix: add the missing files

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

* fix(audio): isolate zero-gate state per encoder

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

* refact: reduce diffs

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

* refact(audio): simple refactor

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

* fix(audio): avoid waiting on playback callback locks

Use one PCM try_lock attempt and preserve queued samples during contention. Replace readiness locking with per-stream atomic status and report callback errors from the receiving thread.

Cover callback progress, retained audio, recovery, and poisoned-buffer handling.

* fix(audio): restart capture after processing errors

Stop further processing until the service recreates the stream.
Document the guard as defensive recovery for an unconfirmed failure.
Group capture and resampler submodules under their parent directories.

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

* audio: report capture queue contention drops separately

- Add contention_dropped to loss reports while preserving total drop counts
- Document packet rejection on contention even when buffers are available
- Extend existing contention and saturation test assertions

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

* refact unit tests

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-10 16:00:58 +08:00
fufesou
978e2e28b9 fix(audio): restart capture when the device is unavailable (#16142)
* fix(audio): restart capture when the device is unavailable

* refact: remove low-value test

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-10 15:32:49 +08:00
rustdesk
5cfe136fb0 fix mac sign 2026-09-10 12:08:47 +08:00
Maison da Silva
14a5ed45d9 Revise full description for Android app pt-BR (#16140)
Updated documentation links and improved text clarity. pt-BR
2026-09-10 10:56:34 +08:00
Maison da Silva
435fe24a81 Fix formatting and punctuation in full_description.txt (#16138)
Fix formatting and punctuation in full_description.txt
2026-09-10 10:36:07 +08:00
Kauan Kelvin
3ffee7c1ff fastlane: add pt-BR Android metadata (#16135)
* fastlane: add pt-BR Android metadata

Signed-off-by: Kauan Kelvin <kelvinkauan722@gmail.com>

* Update fastlane/metadata/android/pt-BR/full_description.txt

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

---------

Signed-off-by: Kauan Kelvin <kelvinkauan722@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-10 10:33:54 +08:00
YannAntunes
97190f715b fastlane: add es-ES Android metadata (#16136)
Signed-off-by: Yann Antunes <yannantuneslopes123@gmail.com>
2026-09-10 09:58:31 +08:00
rustdesk
aa232a9dfa chore(flutter): pin our own git plugins instead of tracking their HEAD
dash_chat_2, window_manager and desktop_multi_window named only a url, so
the lock recorded `ref: HEAD` for them. That holds while the lock is used
verbatim, but any re-resolution -- an unrelated pubspec.yaml edit, a lock
conflict resolved by regenerating -- re-reads HEAD and silently moves the
plugin to whatever the fork's tip is that day. All three forks are ours and
get pushed to, which is how window_manager and desktop_multi_window drifted
across five lock bumps since July with no pubspec.yaml change to show for it.

Each ref is the resolved-ref already in the lock, and all three still match
their fork's HEAD, so nothing resolves differently today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-10 09:14:49 +08:00
fufesou
65edf214b9 fix(macos): recover system-stopped audio capture streams (#16123)
* fix(macos): recreate system-stopped audio capture streams

Pin CPAL's ScreenCaptureKit stop notifications and retain interruption
state with each capture stream. Recreate an interrupted stream through
the existing service restart path, outside the backend error callback,
and resend its audio format. Late callbacks cannot restart a replacement.

A natural -3821 stop was observed with the remote connection still open.
Its OS trigger remains unknown and it has no deterministic natural
reproducer. Controlled verification stops the real SCStream and delivers
an explicitly marked -3821 notification; this is not a natural failure.

Dependency: https://github.com/rustdesk-org/cpal/pull/5

Validation: requested macOS Rust and Flutter debug builds; three full-crate
regression tests; build check without ScreenCaptureKit; two controlled
recreations on one connection with independently recorded receiver audio.

* chore(macos): log audio capture startup and resumed samples

* Update deps, cpal

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-09 22:34:25 +08:00
RustDesk
bac8323e5d Wayland portal staged errors (#16118)
* wayland: say which step of the portal handshake failed

The XDG portal handshake is four sequential requests, and every way it can end
badly -- the user declining, the request being dismissed, a timeout, the portal
being absent or dying mid-handshake, the stream list coming back empty -- left
`request_remote_desktop` through one `bail!` carrying one string.
`map_err_scrap` then guessed a cause by looking for "dbus" or "pipewire" in
that string. Since that string always mentions "PipeWire library", a decline
and a three-minute timeout both came out as "Wayland requires higher version of
linux distro. Please try X11 desktop or change your OS." On Ubuntu 21+, where
the mapping passes the text through untouched, they came out as raw English
pointing at an unrelated GitHub issue.

The response code and the D-Bus error were in hand at the moment of failure and
were being dropped: `handle_response` collapsed all of it into one
`AtomicBool`. Record it instead, tagged with the stage that produced it, and
let the app side look the tag up. `map_err_scrap` gains one leading branch;
anything untagged -- which is everything the capture loop reports -- takes the
existing path unchanged.

What the peer is told is chosen from the tag, and only from facts the tag
actually carries:

- A decline and an interaction that ended some other way are separate outcomes
  and say so. The Request spec defines response 1 as the user cancelling, and
  guarantees nothing more about 2 than that it ended -- libportal treats 2 as a
  plain failure -- so 2 says the request ended without completing and does not
  guess who ended it or why.
- A timeout says it timed out. It does not say nobody answered: RustDesk passes
  a saved `restore_token` with `persist_mode` 2, and a restored session is
  exactly the case where the portal shows no picker at all, so there may have
  been no dialog for anyone to answer.
- Not reaching the session bus, a portal that answers but does not implement
  what was called, and a grant that fails only when the PipeWire connection is
  handed over, each get their own message. None of the three is fixed by
  restarting the portal, so none of them is told to. Each says only what its
  evidence supports: failing to open the session bus does not prove nobody is
  logged in, and `UnknownMethod` on RemoteDesktop does not prove the portal
  cannot capture a screen. Which interface was called is in the D-Bus message
  that goes to the log; the message to the peer does not claim one.
- What is left -- the portal absent, silent, or failing mid-handshake -- keeps
  the existing `xdp-portal-unavailable`, which is already translated everywhere
  and carries the one remedy that fits: `systemctl --user restart
  xdg-desktop-portal`.
- The Ubuntu-before-21 branch keeps every outcome that says something about the
  machine and yields the three that say what happened to the request.

Two more say less than they could, for the same reason. `streams_from_response`
comes back empty when the response cannot be parsed as well as when there is
nothing in it, so the message says RustDesk did not obtain a usable screen
rather than that the portal offered none. `ElementFactory::make` fails the same
way for a plugin that is absent as for one that will not load, so the message
says the component could not be loaded rather than that it is missing.

The D-Bus error name and message, the portal response code and the GStreamer
factory's own error go to the log. Only the element name also reaches the peer,
because it is the one detail that says which package to look at.

`fill_displays` needs the tag resolved at its own call site: it opens a second
portal session for cursor-based display disambiguation, and its error returns
straight up `check_init` without passing through `map_err_scrap`, so a tag
would otherwise reach the peer verbatim.

Two existing paths change, both necessarily:

- `check_init` no longer wraps `Capturer::new` in `with_context`. The peer is
  shown `format!("{}", err)` (connection.rs), which renders only the outermost
  layer, so that context was replacing the mapped code with "Failed to create
  capturer for display 0".
- The `std::process::exit(-1)` on libdbus' no-reply text is now reached only by
  the capture loop, which is what that self-heal was written for. Every D-Bus
  call in the handshake -- opening the session bus, `get_request_path`, the
  `add_match` inside `handle_response`, `create_session`, and `conn.process` in
  the wait loop -- carries a tag, so a no-reply there is reported rather than
  fatal. It is worth saying plainly what that branch did before: the portal
  proxy has a one-second timeout, so a portal slow to activate could take the
  whole service down.

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

* wayland: lang keys for the staged portal failures

Eight keys, appended to `template.rs` and to every `src/lang/*.rs`. `it.rs`
gets empty values, as AGENTS.md requires -- it is maintained by hand by its
translator. No `en.rs` entries: each key is already its own English display
text, which is also what an older peer falls back to.

One carries a `{}`, the name of the GStreamer element that could not be created
-- the one detail that tells a user which package to look at. `lang.rs`'s
`extract_placeholder` resolves a key by replacing the first `{...}` with `{}`,
which is why the server sends the value still inside the braces and why the
scrap side strips braces out of any detail before it gets there. Everything
else technical stays in the log: a D-Bus error name or a portal response code
in a dialog is noise to the person reading it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:53:13 +08:00
RustDesk
f164c9a9df Dead peer recovery (#16117)
* webrtc: recover from a silent peer in about 8s instead of 30s

A controlled peer that is killed, switched away by a user switch, or rebooted
leaves no trace on a UDP transport: there is no reset to receive, so the session
sees silence, and only the 30s inactivity timeout ends it. By then the remote
machine may have finished rebooting and be reachable again, while the user has
been watching a frozen frame the whole time and is then told the peer reset the
connection.

ICE already knows sooner. It reports Disconnected about 5s after it stops
hearing from the peer, from its own task, so it stays accurate even while this
loop is busy sending. That state is transient by design - a Wi-Fi roam or a
sleep/wake recovers from it - so it is treated as suspicion, not as death: three
more seconds with the transport receiving nothing, and the session reconnects.
Receive progress cancels the suspicion, so a peer that is merely slow, or one
ICE was late to clear, is not dropped.

This only reaches the existing recovery sooner; it does not replace it. The
first reconnect goes out immediately and, if it fails, falls into the same
retry the UI already applies to any unexpected disconnect. The restart
reconnect event is reused deliberately: it is what asks for exactly that, with
no error dialog in front of it, and the UI shows "Connecting..." for it rather
than anything about restarting. Its five-minute grace stays reserved for a
restart the user actually asked for - silence is no evidence of a reboot.

The 30s timeout is unchanged and still backs every transport. TCP and WebSocket
are untouched. The controlled side is untouched: it detects a dead controller
on the same 30s, which wastes some capture but nothing a user sees.

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

* kcp: recover from a silent peer on the endpoint's own clock

KCP is the other transport with nothing to receive when the peer dies, and it
was the slower of the two: its endpoint reaps a connection only after 60s
without a packet, which is past the 30s inactivity timeout above it, so in
practice nothing but that timeout ever noticed.

The endpoint already tracks when each connection last heard from its peer and
now exposes it, so this reads that rather than anything derived from the session
loop - it keeps answering while that loop is busy sending. Its liveness ping now
goes out about every 2s rather than every 10s, so silence means the peer rather
than an idle link, and eight seconds of it is several missed pings.

Same threshold and the same recovery as the WebRTC half, so a user sees the same
thing on either transport.

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

* review: time the inactivity window off receive progress, bound the parting send

Two things the review found, both on the controlling side.

The 30s inactivity window still ran off completed messages alone, so the probe
added for the fast path did not fix what it was added for: a message larger than
the transport's fragment size yields nothing until its last fragment, and a peer
sending one steadily was still timed out mid-transfer. It is now timed off
whichever is later, a completed message or receive progress. Transports that
report no progress leave that at its starting value, so nothing else moves.

The parting close-reason send for KCP waited on send capacity with no deadline
of its own, and a queue a dead peer will never drain held the finished session's
thread until the endpoint reaped the connection a minute later. Bounded once the
peer has been declared gone. Still attempted rather than skipped: if the loss was
one-way the peer does receive it, and drops its side immediately instead of
waiting out its own timeout - which is also the one case where the note below
resolves itself.

Recorded from the same review, for the case none of this targets - a peer that
is alive behind a path that broke for five to ten seconds and then healed.
Giving up cannot deliver a close there, because the path is still down at that
moment, so the controlled side keeps the old connection until its own 30s
expires. For up to twenty of those seconds it holds two authorised connections:
its connection manager lists both, and the stale one reports a growing delay
that pins the shared frame rate low for the new one. Input is unaffected
throughout and both recover once the stale connection goes, so this trades
twenty-two seconds of a frozen, uncontrollable session for a controllable one
that looks wrong for a while. Closing the displaced connection is controlled-side
work and belongs with the rest of it, not here.

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

* review: reject a disconnected cached session, tidy the detector

hbb_common: `is_reusable_for` now also rejects a session ICE reports
Disconnected, so a caller is not handed one that already carries the hint; and
the receive-progress test no longer races `next()` against a sleeping sibling.

Here: the `is_some()` guard on the progress comparison was dead, since a
transport answers `None` for its whole life and `None != None` is already false.
The parting-send deadline is a `Duration` like every other constant around it
rather than bare milliseconds. And the comments are cut back to what is not
already evident from the code they sit on.

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

* review: keep the legacy UI's retrying error when the peer goes silent

`restarting-show` is a Flutter control event; Sciter has no case for it and
falls through to a plain dialog, which `check_if_retry` marks non-retryable
because its type is not `error`. So on that build the new detector would have
replaced a timeout that reconnects on its own after 30s with a dialog waiting
for a click at 8s - a regression for the one path this was meant to shorten.

Send it the message the timeout already sends, so its behaviour is unchanged
apart from arriving sooner.

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

* review: keep the 30s watchdog hard, and let Android's picker hold the reconnect

Timing the watchdog off receive progress gave away its upper bound. A fragment
bumps the counter as it arrives, ahead of the framing checks that would reject
it, so a peer sending one `FRAG_MORE` every twenty seconds and never a
`FRAG_END` refreshed the deadline forever while the reassembly buffer grew
toward `MAX_FRAME_LENGTH`, a gigabyte away. What it bought - a clipboard image
that takes longer than thirty seconds to arrive is not a dead peer - is a
pre-existing problem that predates this branch and can be fixed on its own.
Receive progress goes back to the one job it was added for, which needs no
deadline of its own: telling a transport that has gone quiet from one that is
still delivering, so ICE's disconnected hint is not acted on mid-transfer.

The Android document picker suppresses a `Connection Error` while it is open
and remembers to reconnect once it closes. The peer-gone break reconnects
under `restarting-show` with a `Connecting...` title, which matched neither
half of that test, so an eight-second stall behind an open picker - Doze and
background throttling produce them - threw a dialog up behind the picker and
lost the deferred reconnect. It is now named there by its own title rather
than by its type: an explicitly restarted remote device sends the same type
from a path this leaves alone, on every transport, and deferring that one too
would be a change to sessions this has no business touching.

The two limits are still not hard upper bounds, and the comment saying so was
wrong about why. A send is awaited inline in this loop, so one in progress
delays the tick that checks them - bounded on WebRTC by the timeout the stream
was built with, not bounded at all on KCP, whose framed stream is constructed
with none. The 30s watchdog beside it shares the loop and the same delay.

Left alone deliberately. `restarting-show` reconnects without the backoff its
`restarting` sibling uses, which can loop while each round gets far enough to
establish a session and then loses the transport within eight seconds; a
cooldown there would also delay the recovery this exists for when a peer
really does come back, and the loading it shows can be cancelled. And the KCP
limit reads an accumulated silence rather than a transient hint, so unlike the
WebRTC grace it needs no second sample to confirm - one would only move eight
seconds to nine.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:56:41 +08:00
21pages
080211ff36 improve qos (#16082)
* first improve

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

* fix(qos): avoid reducing FPS for transient network jitter

  Require consecutive bad samples to confirm congestion before normal
  FPS reductions. Prefer bitrate reduction when ABR is available and
  allow an outstanding probe to complete before evaluating its effect.

  Recover FPS faster on fresh good samples while preserving severe-delay
  and timeout protection.

  Add regression coverage for jitter, bandwidth changes, and multiple viewers.

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

* test(qos): virtual clock and a closed-loop link simulation

Tests drove time by moving `Instant`s into the past, which panics on a host
that booted less than two minutes ago, and the ABR smoke had to detect ratio
changes to keep its fake clock consistent.  `VideoQoS` now reads the clock
through `now()`; tests set a virtual instant and advance it.

`tests/sim.rs` drives the controller the way `Connection` does, over a link
with variable frame sizes, wobbling capacity, heavy-tailed jitter,
retransmission stalls and link stalls, with both a bitrate-targeted
(VP8/VP9/AV1) and a fixed-rate (hardware) encoder model.  It prints one
table row per scenario; the assertions arrive with the controller changes.
The short-stall smoke sweeps the stall phase instead of three fixed values.

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

* fix(qos): keep jittery but healthy links fast, drain congestion with bitrate

A weak home Wi-Fi with plenty of capacity but frequent jitter and the odd
stall ended up at about 10 fps: single bad replies, the two second probe
timeout and a slow climb back each took their share.  The controller now
treats a transient stall and a saturated link differently.

- The probe timeout no longer slams the frame rate to 2.  Every second the
  probe stays out beyond the first halves it instead, and the late reply
  that finally closes the probe does not reduce again.
- After a reduction, good replies return halfway, then fully, to the level
  held before it.  A restored level that congests within five replies
  becomes a lower ceiling, so a real capacity drop converges instead of
  oscillating.
- VP8, VP9 and AV1 run CBR against timestamps: fewer frames only means
  bigger frames.  While the bitrate can still be reduced the frame rate
  keeps its floor, and three bad replies in a row confirm congestion and
  halve the bitrate instead of stepping it down by a fifth every three
  seconds.
- `bitrate_first` now means the bitrate can actually still drop; at the
  floor, congestion during the adjustment cooldown reduces the frame rate
  (Greptile).
- `avg_delay()` subtracts the baseline with `saturating_sub`; at exactly
  the baseline it returned the whole delay, which kept the bitrate of a
  stable high-RTT link from ever recovering.
- `HISTORY_DELAY_LEN` kept three samples, not two.
- Every ratio adjustment resets the dynamic screen counters, so a long
  congestion episode cannot make a static screen look dynamic
  (CodeRabbit).
- One `qos_trace` debug line per probe reply and per timeout, for replay
  through `tests/sim.rs`.

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

* fix(qos): adapt each viewer from its own target, not the shared minimum

`user_network_delay` started every viewer's adaptation from `self.fps`,
the minimum over all viewers.  One congested viewer therefore pulled the
others' targets down with it, and when it recovered the stream stayed low
until the others had climbed back on their own.  The per-viewer memory
added for restores made the coupling worse: a viewer recorded another
viewer's low rate as its own pre-congestion level (Greptile).

Each viewer now adapts from its own `delay.fps`, falling back to the shared
value only for its first reply; `adjust_fps` keeps aggregating the minimum.
The replay test now fails on a malformed trace value instead of dropping
it (CodeRabbit).

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

* test(qos): paired network trace, bitrate-conserving encoder, twenty seeds

The simulator drew every random quantity from one stream, so two controllers
with the same seed saw different links as soon as they produced a different
number of frames; the A/B was not paired.  The link trace (capacity wobble,
stalls, loss events) is now generated before the run from a network stream of
its own, and encoder noise and probe jitter have separate streams.

The CBR model let a scene change add three frames' worth of data every five
seconds without clawing it back, which raised the offered load of any
controller that lowered the frame rate (up to +27% at 2 fps).  The encoder now
repays the surplus over the following frames and the size spread has mean one,
so the long-term load no longer depends on the frame rate.

Every scenario runs over twenty seeds and the assertions bound the
distribution: median of the mean target, worst p10, p90 of the time below half
the limit and of the queue p95.  The bounds state what the product needs, not
what one seed produced.  New columns: produced and delivered frames per
second, delivered frame age, sustained recovery (target at the limit and queue
under 200 ms held for five seconds), cold-start minimum and time to 90% of the
limit.  The replay advances by recorded `t=` deltas when present and is
labelled as the open-loop, FPS-only diagnostic it is.

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

* fix(qos): baseline from the first reply, bitrate cuts on confirmation only

Two findings from the design re-check.

The baseline needed ten replies before it was used, so a stable 180 or
300 ms link spent its first ten seconds read as congested: the frame rate
fell to 5 and the bitrate was cut before either recovered.  The running
minimum is the baseline from the first reply on; the smoothed estimate
takes over once the window is full.

A single reply a second above the baseline, or a single timer tick with the
probe out for two seconds, cut the bitrate by a fifth.  A static screen never
earns an increase back, so repeated stalls ratcheted its ratio down and the
next dynamic episode started from there.  Bitrate cuts now need two bad
replies in a row, or a probe still outstanding at the second tick past two
seconds; the frame rate brake and the severe-reply rule are unchanged.  An
elevated but unconfirmed delay no longer restarts the ratio cooldown, so a
confirmation on the next reply is acted on at once.

`qos_trace` lines carry a millisecond timestamp for the replay test.  The
simulator asserts the intercontinental cold start: minimum target at
INIT_FPS and 90% of the limit within ten seconds.

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

* test(qos): held-out seeds and parameter sensitivity as guards against tuning

The scenario bounds now live in one function shared by the CI run over seeds
1 to 20 and by `robustness.rs`, whose two ignored tests apply the same bounds
to seeds 21 to 120 in blocks of twenty and halve or double each scenario
parameter in turn.  Anyone changing a controller constant or a bound runs
them; a bound that fails on unseen seeds was fitted to the CI seeds.

At this head every held-out block passes, with medians within a few percent
of the CI seeds, while master fails five of five blocks in every home Wi-Fi
row.  The sweep keeps the lead over master in all fourteen variants for the
frame-rate metrics and shows two limits worth knowing: at 6 Mbps of capacity
both controllers hold about 2.4 s of queue p95, and at a drop to 1.5 Mbps
both are poor because the 1 Mbps bitrate floor leaves little to drain with.

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

* test(qos): frame age bounds, wall-clock scene changes, time-indexed probe jitter

The queue metric divides the queued bytes by the nominal capacity, so during a
link stall it reports how long the backlog takes to drain afterwards, not how
long the frame at the head has already waited.  Frame age, the time a
delivered frame spent in the shared path, was computed but not bounded.  It
is now bounded per scenario, as a regression bound set from the scenario
rather than from a run: 1.5 s on the home Wi-Fi rows (isolated stalls of up
to 2.5 s are tolerated, a sustained multi-second backlog is not), 100 ms on a
clean link, 150 ms on a stable high-RTT link (frame age excludes the round
trip, so RTT earns no allowance), and the same bound as the queue on the
bandwidth-drop and mobile rows.

Two residual couplings between controller decisions and the exogenous inputs
are removed: scene changes follow the wall clock instead of the frame count,
and probe jitter is a per-second table drawn before the run, so two
controllers with the same seed meet the same content timeline and the same
jitter.  The moderate-congestion smoke profile now asserts that the frame
rate actually drops, and the held-out test builds its combined summary from
the block reports instead of simulating every seed twice.

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

* test(qos): remove the placeholder viewer from trace replay

  Clear the synthetic viewer created by smoke::session so it cannot
  cap replayed FPS at 15 when the recorded connection ID differs from 1.

  Add a regression test verifying identical FPS sequences for connection
  IDs 1 and 1652, both recovering to 30 FPS.

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

* fix(qos): cap each viewer by its own limit, judge bitrate steps per viewer

Two places still let one viewer's state leak into another's.

The per-viewer target was clamped by `highest_fps()`, the minimum of every
viewer's limit, before being stored, and a new viewer started from the shared
stream rate.  A viewer that lowered its limit dragged the others' targets down
with it, and when it left the stream stayed there until the others had climbed
back; a viewer joining a congested session started at the congested rate.
Each viewer now starts at INIT_FPS, is capped by its own limit only, and
`adjust_fps` keeps applying the shared limit to the aggregate.

`adjust_ratio` paired the maximum delay over viewers with any viewer's
confirmation, so one viewer's unconfirmed 1200 ms spike and another viewer's
two 200 ms replies produced a 20% cut, and a third mild reply a halving, when
each viewer on its own called for five percent.  Each viewer's own delay and
confirmation now decide the step it calls for, and the stream takes the most
conservative one; increases still need every viewer below the threshold.
Single-viewer behaviour is unchanged.

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

* test(qos): replay advances by the wall clock across connections

The replay kept a last timestamp per connection id, so a log with several
viewers each writing once a second advanced the virtual clock once per line
and replayed several times slower than it was recorded.  It now advances by
the delta between consecutive lines whatever their connection.  The replay is
a plain function over the log text, with the environment-driven test as its
entry point, so the time axis can be tested directly.

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

* fix(qos): first reply keeps the ratio cooldown, closing a viewer re-aggregates

A viewer's first TestDelay reply called `adjust_ratio` with no cooldown
check.  With the per-viewer steps, that scan finds the other viewers' still
confirmed evidence, so a viewer joining right after a cut halved the bitrate a
second time inside the three seconds the cut is meant to be observed for.  The
first reply is now one more trigger of the periodic adjustment and keeps its
cooldown; a fresh session still adjusts on its first reply, since its
controller was created long before.  Linux was never on this path.

`on_connection_close` removed the viewer without re-aggregating, so the
stream stayed at the departed viewer's rate until the next tick; the
remaining viewers are aggregated at once.

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

* fix(qos): the newcomer guard belongs to the viewer that joined

Re-aggregating on close applied the one-second INIT_FPS guard that the
departing viewer had set when it joined, so a viewer that connected and
dropped within a second throttled the others to 15 fps for the rest of that
second.  The guard now records which viewer set it and is cleared when that
viewer leaves; a genuinely new viewer is still capped for its first second.

The first-reply ratio adjustment's platform switch is a field instead of a
`cfg!` inside the condition, so the cooldown regression test exercises the
path on Linux CI as well.

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

* fix(qos): every newcomer carries its own start-up guard

The start-up guard had one slot, so a second viewer joining within a second
overwrote the first one's join time, and when the second viewer left the
first one's window was released early.  The join time now lives in the
viewer's own entry, `adjust_fps` caps the stream while any viewer is inside
its first second, and a departed viewer takes its guard with its entry; no
clearing logic is needed (Greptile).

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

* fix(qos): adapt delay baselines and speed up FPS recovery

  - Relearn stable baseline increases from recent fresh replies.
  - Require fresh congestion confirmation between ordinary FPS reductions.
  - Keep automatic FPS reductions above a 5 FPS floor, respecting lower caps.
  - Restore FPS after two good replies, with rollback on renewed congestion.
  - Add regression tests for baseline changes, jitter, and FPS recovery.

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

* test(qos): align FPS floor tests with the 5 FPS minimum

  - Remove the unregistered sustained tests with outdated expectations.
  - Test severe delays and timeouts independently, including lower user
    caps and recovery.
  - Move the 700 kbps scenario into active adaptation tests, checking
    the FPS floor and recovery after bandwidth returns.

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

* fix(qos): a timeout never lifts a target, and the invariants as property tests

The timeout brake floored its output at MIN_FPS + 1, so a viewer whose target
had already reached 1 fps was lifted to 2 by the next tick past two seconds:
bad evidence raising the frame rate.  Inherited from master, where the timeout
set the whole stream to MIN_FPS + 1 outright.  The brake now never exceeds the
target it found, whatever the elapsed time it is told.

The controller's six invariants become property tests over random sessions
(150 seeds, 300 steps, one to three viewers, ABR on and off): a viewer's
target is independent of other viewers; bad evidence never raises a target or
the ratio; joins and leaves only change the aggregation; a bitrate cut is
owned by a viewer's own evidence and not spent again by a newcomer; targets
stay within their caps and the stream is their aggregation; a braked probe's
late reply does not brake again.  The timeout case is also pinned exhaustively
over every target and a range of elapsed times.

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

* feat(qos): log the encode and send pipeline behind RUSTDESK_QOS_VERBOSE

The controller's target frame rate is neither the rate the encoder produced
nor the rate the send path accepted, and two facts kept that gap invisible.
libvpx drops frames on its own when the bitrate cannot carry them, so a
capture round is not a delivered frame.  The video send is inline in the
connection's message loop, so a slow write stalls capture and the delay
probe alike, and the recorded delay cannot tell the two apart.

`qos_video` reports, per second and per display, the capture rounds, the
frames that actually reached a connection, and the longest wait for the
previous frame to be picked up.  `qos_send` reports, per second and per
connection, how long `stream.send().await` blocked and how deep the video
queue is.  Both carry `t=`, so they join with `qos_trace` offline; `replay`
filters on `qos_trace` and ignores them.

The default log level is `debug`, so an unconditional line would land in
every user's log file once a second forever.  Neither line is emitted
unless `RUSTDESK_QOS_VERBOSE` is set, nothing enables it implicitly, and
without it the timing calls are skipped as well.  TestDelay, the controller
and every threshold are untouched.

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

* fix(qos): speed up FPS ramp-up on clean connections

  Double startup FPS after every two fresh low-excess-delay replies,
  up to the viewer's cap. End acceleration on queue growth, timeout,
  or FPS reduction.

  Add regression tests for startup speed, viewer caps, congestion,
  timeouts, and multiple viewers.

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:48:32 +08:00
RustDesk
01dbb76499 server: do not lock the screen for a connection a reconnect replaced (#16124)
A controlling peer whose link dies without a close reconnects, while the
connection it left behind runs on here until its own 30s inactivity timeout.
That one then ends with `on_close("Timeout", true)`, and the lock is gated
only on `lock_after_session_end` and this connection's own `keyboard` - both
set by the very controller that is at that moment working in the session its
reconnect re-established. Nothing anywhere asks whether the session is still
being controlled, so the screen locks under a peer that came back twenty-odd
seconds earlier, and the operator's desk locks itself in front of them.

The lock now also requires that no newer remote control connection of this
session is authorized.

Newer, not merely other. A connection stays in `AUTHED_CONNS` until its
`AuthedConnID` drops, which is well after `on_close` returns, so a symmetric
test would have two of one session ending together each see the other and
neither lock. Ids come from a counter, so `>` orders them: of a session's
connections the last still locks, whether they end one after another or at
once.

Remote control only, and this session only. The other kinds do not keep a
screen in use, and `send_logon_response` clears `keyboard` for a file
transfer, a terminal and a camera view, so none of those reaches the gate at
all - a port forward keeps it, and is kept out only by the client not sending
`lock_after_session_end` on one. Another peer's
session is left exactly as it is: whether its ending locks the screen while
this one is connected is a separate question, and not one a timeout on this
side should start answering.

Every close that asked to lock, not only the timeout. A connection its own
peer has already replaced should not lock the session that replaced it
however it ends, and singling out one reason would leave the same race
reachable through the others.

Two things it does not cover. A reconnect that has not authorized yet is not
in `AUTHED_CONNS`, so a timeout landing while one waits at the accept prompt
or on 2FA locks as before. And the lock is skipped, not handed on: if the
connection that replaced this one later ends through a path that asks not to
lock - a failed send, a stopped service - nothing locks. That is what those
paths already choose for a connection dying of a network error, on the
assumption that the peer will retry, and it is the same assumption this makes.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:06:24 +08:00
Sid
68359a2dd2 fix(macOS): preserve release entitlements when signing (#16125)
* fix(macOS): preserve release entitlements during signing

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

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

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

---------

Signed-off-by: Sidn <3996515+sidnvy@users.noreply.github.com>
2026-09-09 14:48:59 +08:00
cui fliter
5228f91982 fix(screenshot): keep cached image when saving fails (#16120)
Signed-off-by: cuishuang <imcusg@gmail.com>
2026-09-09 07:46:19 +08:00
RustDesk
691830fe89 bump webrtc: revert the T3-rtx probe recovery (#16121)
The probe recovery merged as 692113c87 cost two to four times the p99 on the
workload a remote desktop actually has, and the fork now reverts it: `sctp/src`
returns to 48100bf1, the revision this repository shipped in #15684, with the
benchmark harness and its corrections kept.

It was justified on a fixed frame rate. Nothing is sent while the screen holds
still - the capturer answers WouldBlock and the loop sends nothing - so typing,
reading and clicking are short bursts with silence between them, and a steady
frame rate is what playing video or dragging a window looks like and nothing
else. The difference matters because a steady rate hides the whole effect: the
next frame's SACK exposes a loss whatever the recovery logic does. Measured on
bursts with gaps, after correcting two faults in the harness itself, p99 in ms
for the two seeds:

                          sparse RTT70   sparse RTT150
    48100bf1  09-06 00:06   200 / 208      407 / 507
    b221f13b  09-06 14:00   328 / 804      737 / 826

On a fixed frame rate the two are within noise of each other, which is why this
was not caught. KCP is 138/130 and 257/250 on those rows, ahead of both.

What is given up: a tail loss of exactly four packets recovers in 140 ms rather
than 229, and an idle sender's backlog after a stall offers 1.58x the bytes
rather than 1.08x. A five-packet tail improves, 292 ms to 232.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:31:05 +08:00
fufesou
22b1ed169a fix(audio): update CPAL for WASAPI thread priority (#16110)
* fix(audio): update CPAL for WASAPI thread priority

* update cpal

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-08 16:19:16 +08:00
Jamal Ali
0f0205d336 feat: add Azerbaijani translation (#16103)
Signed-off-by: Jamal <jamalkamaladdin@gmail.com>
2026-09-08 15:20:27 +08:00
rustdesk
e5d473407e fix https://github.com/rustdesk/rustdesk/issues/16086 2026-09-08 12:33:10 +08:00
rustdesk
59fdda3835 file transfer: a send_confirm past the last file no longer panics
`set_stream_offset` indexed `self.files` directly. Its only guard is the
`self.file_num() == r.file_num` check in `confirm()`, and `file_num` counts up
past every file, so it equals `files.len()` once the job is done -- read_frame
at :849 treats exactly that value as "job done". A peer that then sends
`send_confirm` with the matching file_num and a non-zero OffsetBlk gets through
the equality check and off the end of the slice.

Every other site indexing `files` in this file already bounds-checks; this was
the one that did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-08 11:52:06 +08:00
RustDesk
b50fde6910 add the base crate and repoint the moved modules at it (#16107)
* add the base crate and repoint the moved modules at it

`libs/base` (crate `base`) takes the parts of hbb_common that only this app
uses: `fs`, `platform`, `keyboard`, `message.proto`, and 145 of the 177
`config::keys` constants. hbb_common keeps what the server names, and the 32
keys it reads itself are re-exported from `base::config::keys` so call sites
still see the full set through one path.

Sources move verbatim. The only edits inside them are `crate::` prefixes that
now have to say `hbb_common::`; `keyboard.rs` and `platform/windows.rs` are
byte-identical. The crate stays on edition 2018, the edition the moved code was
written under. `log`, `lazy_static` and `anyhow` become direct dependencies so
the bare paths in that code resolve exactly as before, and its winapi features
are spelled out rather than left to feature unification.

Two call sites outside Rust and Cargo had to follow the move: the Android
protobuf source dir, which still pointed at hbb_common/protos for message.proto,
and the three AGENTS.md entries that named hbb_common for options, protos and
file transfer.

`scrap`'s `drm` feature now forwards to `base/wayland_probe`. Left pointing at
hbb_common it would still have compiled, silently dropping the Wayland
socket-probe fallback, so that forward is verified by a build with and without
the feature.

`config::keys` carries a test asserting its names stay disjoint from the ones
hbb_common kept: the glob re-export and the local constants share a namespace,
and Rust prefers the local item silently, so a name added to both sides would
otherwise let client and server disagree with no diagnostic.

Verified: macOS and Linux, debug and release, `--all-targets`; the 177 key
constants diffed name-for-name and value-for-value; the generated protobuf types
compared before and after; every `#[cfg]` gate on a moved import checked against
its original; and every file that was `rustfmt`-clean before this change still
is, compared against master file by file. Windows is checked by inspection only
-- it cannot be compiled here.

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

* one `use` per crate, and write the rule down

`fs.rs` came out of the move with two ungated `use hbb_common::` statements,
because the original single `use crate::{...}` had to give up `message_proto`
to the new crate and the rest was left in a second block. Fold it back into one.

A scan of the whole tree for the same shape finds nothing else: every other file
with more than one top-level `use base::` or `use hbb_common::` is split by a
`#[cfg]` that does not cover the whole block, or by `pub use` next to `use`.
Those are the cases that cannot merge, so AGENTS.md now states both the rule and
the exemption.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:46:42 +08:00
Alberto Xosé Méndez Taboada
e8eead5715 Add Galician language support for UI strings (#16101) 2026-09-07 13:31:19 +08:00
cui fliter
92d787b885 fix: clipboard, preserve uncompressed special format data (#16100)
Signed-off-by: cuishuang <imcusg@gmail.com>
2026-09-07 11:12:52 +08:00
rustdesk
692113c87e bump webrtc: probe on T3-rtx, settle the rest by the SACK, RTO from the latest send
Sending without a congestion window, a T3-rtx resent everything in flight, and
on a 5 Mbps link with 300-500ms stalls that spiralled: seconds of latency and
several times the bytes. The fork now resends everything outstanding on a
timeout only when it packs into four packets; otherwise it resends one packet
and withholds the rest until the SACKs settle them (RFC 4960 6.3.3 E3, F-RTO):
an original acked takes the marks off, a first transmission made after the
timeout acked with the rest still missing sends them, and with nothing else to
tell, a probe acked no sooner than the least RTT after it went out draws the
next probe, two of them settling the rest as lost. A restarted RTO is counted
from the latest send as QUIC does, backs off from the full RTO, and floors at
KCP's 30ms with srtt + 25ms at the least. Lost tails of up to four packets and
sparse small messages under loss now recover at KCP's pace where they trailed
it by a frame; a stall no longer resends the backlog behind itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-06 14:46:52 +08:00
bovirus
dc04b911a1 Update it.rs (#16085)
* Update it.rs

* Update it.rs

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-09-06 11:29:17 +08:00
rustdesk
254d98129d ci: use thin LTO for the armv7 sciter build
The nightly armv7 sciter build aborts while compiling the final `rustdesk`
binary:

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lq6xFoeEmjcuKwRx1GfdQ2
2026-09-06 10:52:37 +08:00
rustdesk
942810d432 bump hbb_common: main after the webrtc merge
rustdesk/hbb_common 3d6fb2c, the merge of PR #579; the tree is the one
470612b already pointed at, now reachable from main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-06 00:24:04 +08:00
RustDesk
ae6af2de43 Webrtc (#15684)
* feat: add rendezvous WebRTC signaling fields

* feat: route WebRTC ICE on controlled side

* feat: race WebRTC as a direct transport enhancement

* fix: route WebRTC ICE through rendezvous paths

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

* fix: preserve WebRTC transport preference

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

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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

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

* 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

* 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

* 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

* 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

* 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

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

* 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

* 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

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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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.

* 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.

* 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.

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* bump hbb_common: quiet the webrtc-rs warnings that describe the race's normal outcome

Cancelling the transport that lost the race, and trickle checking before it
holds a pair, are what the design does on every session that connects - and
webrtc-rs reports both at warn, 90 lines of a 386-line controlled-side log,
beside connections that succeeded. agent_internal and peer_connection drop to
error; agent_gather keeps warn, since an unreachable STUN server is the one
upstream signal that explains a session which never connected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M54JAqUK4RynudFou89hod

* port_forward: restore the `?` the close removal left as a match

Dropping the explicit close_webrtc() from the parse-error arm left a match
that only re-spells `?`; master just reworked this function, so the branch
now leaves port_forward.rs untouched.

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

* l10n: the two WebRTC keys were missing from Urdu

Every other lang file on the branch carries them; ur.rs was skipped when
they were added.

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

* udp: make the punch deadline absolute, so a talking peer cannot defer it

`select!` rebuilds every arm each iteration, so the relative retry sleep was
restarted by each datagram that arrived before it fired. The peer sets that
rate, and an old-build peer's empty datagrams match no arm and loop without
even the recv-error pause, so MAX_TIME went unchecked and the retransmit was
starved with it. `udp_nat_connect` awaits the punch ahead of the KCP timeout
and nothing above it bounds the phase, so the punch held the direct race open
and the relay fallback out of reach for as long as the peer kept sending.

Absolute instants for both clocks. The new test floods empty datagrams for
four times the deadline: the punch now ends at 3s where it ran the full 12s.

Also note at the symmetric-NAT branch that WebRTC not following the legacy
relay decision there is deliberate, so it is not later "fixed" into agreement.

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

* bump webrtc fork: MTU-safe bundles, a reordering window, tail loss within the RTT

rustdesk-org/webrtc cc6633bc, three commits on 825a0a48, all on the path
that sends without a congestion window:

Both bundlers counted a DATA chunk by its payload alone; with the header and
padding counted, bundles of small chunks stay within the MTU, and the fragment
payload rounds down to 1160 so a full chunk does too. A chunk is fast
retransmitted at most five times, KCP's IKCP_FASTACK_LIMIT.

A frame's chunks go out within microseconds of each other, so on a path that
jitters the send-order rule resent every chunk that landed behind three of
its siblings: 2.7x the payload on the wire at 10ms of jitter, and on a link
without the room for that, a queue that fed on itself. A reordering window,
RACK's, makes evidence count only from what was sent a quarter of an srtt
after the chunk once the path is seen to reorder, widening on the duplicate
TSNs the receiver reports. 5 Mbps, 1% loss, 20ms jitter: 600 of 600 frames
at a 98ms mean where 290 arrived at 6.2s.

A chunk lost at the tail of a burst has only T3-rtx, which ran from floors
sized for a 200ms delayed ack and restarted only on the tail's predecessor's
ack: 600ms and more. Every DATA chunk now carries the I bit, the floors are
KCP's shape, and a fast retransmission restarts the timer. One 200-byte
message per frame at 5% loss: 9 of 600 later than 200ms, from 42.

Random loss without jitter is unchanged at every rate and frame size.

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

* bump webrtc fork: T3-rtx restarts only for the earliest chunk's fast retransmission

rustdesk-org/webrtc 2b8e55bc. Sending without a congestion window, a fast
retransmission of any chunk restarted T3-rtx, so a chunk past the fast
retransmission cap - left to that timer - never reached it while later
chunks kept being resent, which a lossy stream does every couple of frames.
The timer is the earliest in-flight chunk's, and only its resend restarts
it now. Nothing else changes; the benchmark is unchanged.

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

* bump webrtc fork: T3-rtx restart on fast retransmission while shutting down too

rustdesk-org/webrtc 48100bf1. The restart for the earliest chunk's fast
retransmission reached only the Established branch of the write loop; the
shutdown states still carry data in flight and recover it the same way, so a
closing association could still resend everything on a loss its fast
retransmit had already recovered. Both branches share one helper now.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-06 00:21:28 +08:00
RustDesk
3fc11c0f81 port forward shared conn (#16062)
* hbb_common: bump to the port-forward-mux proto

Also latches PortForward.multiplex into login_scope_digest, which
destructures PortForward's fields exhaustively by design (a new field
must be latched or deliberately ignored to compile).

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

* port_forward_mux: window accounting and channel frame builders

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

* port_forward_mux: fix RecvWindow counter overflow on long transfers

Replace cumulative accounting (granted/received) with remaining credit
tracking to prevent u32 overflow after 4 GiB of data on a single channel.
Wire behavior is identical, but the fix allows large file transfers
without mid-stream channel closure.

Add regression test for 8 GiB transfer to verify fix.

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

* port_forward_mux: credit-windowed relay halves and channel coordinator

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

* server: PortForwardMux channel table and per-channel tasks

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

* server: multiplexed port-forward connections stay in the protobuf loop

Wire PortForwardMux into Connection: take the multiplexed path at login
when the controller sets PortForward.multiplex, route
PortForwardChannel frames to it from on_message, sweep the channel
table's targets after open/close, and clean it up on connection close.

Introduce is_port_forward() (socket-based or multiplexed) and use it
at the four sites that classify the connection, so a multiplexed
connection stays in the message loop, gets TestDelay keepalives, and
reports features.port_forward_mux in PeerInfo. The three sites that
break into the raw pipe loop or gate the keepalive still check
port_forward_socket specifically, since a multiplexed connection must
not take that path.

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

* cm: update a port-forward row's targets as tunnel channels come and go

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

* port_forward_mux: controller tunnel with a single-writer stream loop

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

* port_forward_mux: publish Muxed before spawning the tunnel loop

Publishing after spawn let a loop that dies immediately reset the state
first, so the later publish pinned it at Muxed with a dead handle
forever. Also adds a test pinning open-before-data ordering across many
concurrently opened channels, and drops an unused Clone derive.

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

* port forward: share one multiplexed tunnel across a window's listeners

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

* port forward: fix round 1 review findings

Drop the mux default-false assignment now that definite-assignment proves
every path that reads it has set it; the enable-port-forward-mux config
commit picks up the missing attribution trailers; the default-on test
pins the enable- prefix itself rather than option2bool's weaker fallback.

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

* port_forward_mux: end-to-end tests over a loopback tunnel

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

* port_forward_mux: fix bulk test's premature half-close, pin the half-close limitation

many_channels_echo_concurrently_and_a_bulk_one_does_not_starve_them dropped
its bulk write half as soon as writing finished, which shuts down the write
side of the socket and, by design (see the design doc's TCP half-close
non-goal; today's run_forward does the same), ends the whole channel. Keep
the write half alive until the reader is done so the test measures
starvation, not half-close. Add a_local_half_close_ends_the_whole_channel to
pin that limitation in code.

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

* port_forward_mux: cap send credit and other final review fixes

Fix 1 (critical): clamp SendCredit to MAX_SEND_CREDIT (= CHANNEL_WINDOW)
in both new() and add(), so a peer with tunnel permission can no longer
advertise an unbounded window and force the controlled side's unbounded
FrameSink::Direct sink to buffer unlimited target data per channel.

Fix 2: rename the "starve" test to many_channels_echo_concurrently and
drop its (untrue) starvation claim, since it opens every channel before
the bulk transfer starts. Add a_channel_opened_during_a_bulk_transfer_
is_served_promptly, which opens the small channel while the bulk one is
demonstrably mid-flight.

Fix 3: only look up the tunnel permission for `open` frames in the
PortForwardChannel arm of on_message, instead of once per data frame.

Fix 4: two rustfmt deviations in connection.rs (matches! wrapping and a
tuple literal), fixed by hand without a blanket cargo fmt run.

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

* port_forward_mux: report a refused channel's reason as an error dialog

The controlled side already answers a refused port-forward channel with
opened { success: false, message }; on the multiplexed path TunnelHandle::
on_frame only logged that message at debug and closed the channel, so the
user saw a closed connection with no explanation, worst on the RDP path
where only the RDP client's own error remained. on_frame now returns the
message the window should show, deduplicated per distinct reason (capped
at MAX_REPORTED_OPEN_ERRORS) so one page load's dozen refused connections
surface one dialog per reason instead of a dozen.

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

* Use on_error for refused-channel dialog in tunnel_loop

Redirect the refused-channel error through the standard on_error path
instead of calling msgbox directly, for consistency with other errors
in the port-forward flow.

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

* port forward: apply the whole-branch review

Correctness:
- listen(): the Legacy arm is merged with the Claimed arm. On its own it
  ignored outcome.local_eof, so a client that hung up during login still
  got a target connect, an audit record and a CM row on the controlled
  side, and ignored outcome.mux, so a peer upgraded while a legacy window
  stayed open answered as a tunnel while the controller went raw.
- Refusal dialogs are deduplicated per quiet spell (10 s) rather than per
  tunnel lifetime; the lifetime set went silent for the rest of a
  long-lived window after the first burst.
- Android's CM listener handles UpdatePortForward; it fell into `_ => {}`.
- relay_socket_to_tunnel reads into one scratch buffer per channel and
  sends an exact-size copy. A frame owning its 64 KiB read allocation
  pinned it until sent, once per byte on interactive traffic.

Consistency and cleanups:
- The controlled side's refusal text is the raw pipe's wording, RDP
  substitution included.
- connection.rs: the PortForwardChannel arm is a one-line hook, the CM
  label is pushed from the 1 s tick alone, and the unreachable inner.tx
  fall-through is gone.
- The Ready enum is removed; wait_ready() returns Option<Claim>.
- SendCredit::add wakes with notify_one alone.
- on_ui_command() replaces the two ui_receiver handlers in listen().
- TunnelHandle is no longer re-exported (unused-import warning).

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

* port forward: a legacy window stays legacy until it is reopened

Review: the merged `Claimed | Legacy` arm gave a legacy window a hot
transition to a tunnel — every accept re-negotiated, and a peer upgraded
while the window stayed open was promoted underneath live connections.
The product does not need a mode switch inside a window's lifetime, and
the transition was extra state-machine surface for nothing: reopening
the window picks up an upgraded peer.

The two arms are separate again. `Claimed` negotiates once and the
peer's answer fixes the window's mode. `Legacy` logs in for every accept
as before, asks for no tunnel — `LoginConfigHandler::port_forward_mux`
carries the request per login, so the raw pipe never has to talk to a
peer that thinks it agreed to multiplex — and ignores what the peer
reports. Both arms keep skipping a local socket that hung up during
login.

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

* hbb_common: bump to main with rustdesk/hbb_common#594 merged

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

* server: admit only INITIAL_WINDOW on a channel before opened

The demultiplexer accepted CHANNEL_WINDOW into a pending channel's
unbounded queue, four times the bound the channel task enforces once
it polls. The window now starts at INITIAL_WINDOW and is widened right
before `opened` advertises the rest.

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

* port_forward_mux: a tunnel ends when its window drops the Tunnel

The loop held its own handle and state sender, so once the window
closed nothing was left to stop it: it kept answering TestDelay and the
peer connection, CM row included, lived on until the peer went away.
`Tunnel` now owns a watch sender nobody sends on; the loop's receiver
errors when the last `Tunnel` drops, and the loop ends.

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

* port forward: one tunnel per mapping, bound to the authenticated target

The login latches `PortForward.host`/`port` into the session scope and
approval is shown that target, but a window-wide tunnel let any later
`open` name another target with only `enable-tunnel` rechecked. A
tunnel now belongs to one listener and serves the one target its login
authenticated: the controlled side refuses an `open` for any other
target, and a window with several targets uses one connection each,
approved on its own.

With one owner per tunnel the claim needs no waiters: `Establishing`,
`Claim::Wait` and `wait_ready` go, and `try_claim` becomes a plain
read. The CM label that followed a tunnel's targets goes with them; a
row shows its mapping's target, as before.

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

* port forward: the legacy comment names the mapping, not the window

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

* port_forward_mux: a window violation drops the channel on the spot

Both demultiplexers only queued a `Violation` and left the entry until
the channel task woke and exited, so a peer that kept sending past the
window queued one more entry per frame in the meantime, bounded by
nothing. The entry now goes the moment `accept` fails; later frames for
that id are unknown-channel noise.

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

* port forward: the login's target travels with the accept, not the handler

`listen()` wrote `lc.port_forward` (and, on this branch, `port_forward_mux`)
into the window's shared `LoginConfigHandler` before connecting, and
`create_login_msg` read them back only when the peer's `Hash` arrived.
Two mappings logging in at the same time could therefore swap targets:
on master that bridged a local socket to the wrong target, and with a
tunnel bound to its login's target it also left the mapping refusing
every later accept until it was recreated.

The target is now a `PortForward` carried by the interface clone that
handles one accept, passed explicitly down to `create_login_msg`; the
handler no longer has a field to race on. No lock spans the login.

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

* port_forward_mux: pin permission revocation and whole-tunnel failure in tests

Both already hold; the review asked for them to be stated. `enable-tunnel`
turned off mid-session refuses the next `open` while the live channel
keeps relaying, and a dead tunnel ends every channel on it together,
after which the next accept establishes again on the same `Tunnel`.

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

* port forward: the legacy comment names re-adding the mapping only

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

* port forward: the raw pipe runs the code it always ran

The multiplexed login had replaced `connect_and_login`, so a mapping
with the setting off, a peer without the feature, or a listener latched
`Legacy` still went through the tunnel's state machine, the capped
pre-read and the changed local-EOF rule. Feature off now means the old
code: `listen()` keeps its accept arm and `connect_and_login` as they
were, and the tunnel is a branch taken only when the setting is on, in
`establish_tunnel` with its own `connect_and_login_mux`. The one line
the raw path does differently is the target riding with the accept's
interface clone instead of the shared handler.

`get_port_forward_mux_enabled` had one caller and moves in here, so
`common.rs` is untouched.

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

* port forward: a UI login answers the challenge its own connection was given

`handle_login_from_ui` hashed the typed password against `lc.hash`, the
window's shared handler field, and the window's password prompt is
broadcast to every listener. With two mappings both waiting on that
prompt, the `Hash` that arrived last had overwritten the other's, so
one of the two answered the wrong challenge and failed to log in.
Master shares the same state and broadcasts the same way.

The `Hash` is now a parameter of the login; `Session` keeps it beside
the connection it belongs to, and the per-accept clone that
`with_port_forward` makes gets a slot of its own. `lc.hash` stays for
`handle_peer_info`, which only needs the salt, and that is per peer.

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

* port forward: a mapping without its hash waits for it before answering the prompt

The window's password prompt is broadcast to every mapping, and can
reach one whose own connection has not received its `Hash` yet. That
mapping used to answer anyway, with a digest over an empty challenge:
the peer refused it and counted a failed attempt, and the empty-salt
result was written into the shared `lc.password`, where the mapping that
prompted had just stored the right one and the next `handle_peer_info`
would persist whatever was there.

The connection's challenge is now `Option<Hash>`, `None` until
`handle_hash` runs, and `handle_login_from_ui` sends nothing without it.
The mapping that prompted stores the salted password in the shared
handler, and the waiting one logs in with that against its own challenge
when its `Hash` arrives, without prompting again.

Test: A answers its prompt, the same broadcast reaches B before its
hash, B sends nothing, B's hash arrives and its login carries B's
challenge and B's target with no dialog. It runs the real `handle_hash`
for B.

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

* port forward: the tunnel's login is the raw pipe's, asked for by a window flag

Master's fix for the shared login slots (#16069) keeps the target and
the challenge in the window's `LoginConfigHandler` and serializes the
mappings' logins with a turn lock, all inside `port_forward.rs`. This
branch had carried a broader shape of the same fix, a `with_port_forward`
on `Interface` and the target and `Hash` as parameters through the login
functions, which every caller had to follow. That is gone: `Interface`,
`Session`, `create_login_msg`, `send_login`, `handle_hash` and
`handle_login_from_ui` are as on master.

What the tunnel needs on top is one bit in the login, `multiplex`. It is
a window flag beside `port_forward` in the handler, set once in `io_loop`
before the window's mappings start, so an accept's claim and its login
read the same value; the setting takes effect for windows opened after
it changes. `connect_and_login_mux` is now master's `connect_and_login`
with the tunnel's three differences and the same `hash_arrived` and
`login_from_ui` calls. The raw pipe is master's, line for line.

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

* hbb_common: bump to main with rustdesk/hbb_common#595 merged

840c8ec..f94e3fe is that one merge: the five local settings custom
clients could not preset.

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

* port forward: the off switch gets a checkbox in Settings → General

`enable-port-forward-mux` was readable only by editing the config file.
It is a local setting of the controlling side, so it sits with the other
outgoing ones, after "Open connection in new tab", with a tooltip saying
what it does.

The two new keys are translated in every language. The three that the
mobile file manager added, "Export", "Export Logs" and "Import Folder",
were empty everywhere but five languages; they are filled in too, and
Korean's "xdp-portal-unavailable" with them.

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

* Urdu: fill the backlog of empty and missing translations

ur.rs had fallen behind: 104 keys carried an empty value and 35 keys the
other languages have were absent altogether. Both are filled in, the
missing ones in the order template.rs lists them.

Eight entries stay empty on purpose. They are keys that only ur.rs still
carries, absent from template.rs and from every other language, so their
English source cannot be recovered and nothing reads them:
remember_account_tip, os_account_desk_tip, another_user_login_*_tip,
xorg_not_found_*_tip and no_desktop_*_tip. Twelve more dead keys keep
the values they have; removing either group is a separate decision.

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

* Urdu: drop the keys template.rs no longer lists

The twenty keys removed here are absent from template.rs and from every
other language file; ur.rs was the only one still carrying them, eight
of them with no value at all. They are leftovers of features that are
gone: the plugin menu, the OS-account login prompts, the Xorg and
no-desktop errors.

ur.rs now holds exactly the template's key set, all of it translated.

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

* port forward: closing the tunnel reaches channels parked on their socket

A channel whose far end neither reads nor writes has both relays parked
on the socket, not on the inbound queue, so `close_all` dropping the
queue's sender woke neither: the socket and both tasks lived on until
the far end hung up. Both sides now hold a per-tunnel teardown signal
that `run_channel` selects on beside its own cancel, and `close_all`
sends it after clearing the map.

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

* port forward: a mapping latched to the raw pipe logs in without asking for the tunnel

The login copied the window's `port_forward_mux` into `multiplex`, so a
mapping that had latched to the raw pipe on an old peer kept asking for
the tunnel. Once that peer was upgraded it answered with a tunnel while
the controller switched to raw framing, and every later connection on
the mapping was dead until it was re-added. The login now carries its
own `port_forward_multiplex`, filled with the target under the turn
lock: the probe asks, the raw pipe does not.

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

* port forward: a channel opened as its tunnel closes still gets the teardown

`open` can straddle `close_all`: the claim passed, the frame receiver was
still alive, and the channel subscribed after the signal had gone out.
`watch::subscribe` marks earlier sends as seen, and the entry sits in a
map that was already cleared, so nothing would ever end it. The signal is
now a level: `close_all` raises it with `send_replace`, which stores even
with no channel live, and `run_channel` waits for the value rather than
for a change.

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

* port forward: the connect guard counts a live tunnel as connected

`connect_port_forward_if_needed` returned early only for a raw-pipe
socket; called again with a tunnel up it would have built a second
`PortForwardMux` and dropped every channel of the first. Not reachable
today, since the logon response is sent once, but the other checks in
this change already read `is_port_forward()`.

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

* Urdu: the two terminal clipboard keys master added

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

* port forward: a tunnel's TCP stream refuses packets over twice MAX_FRAME

The codec takes a header declaring up to 1 GiB and hands the packet up
only once it has all arrived, so the channel window bounded what the
peer may send, not what this side buffers. Both sides now cap the codec
at 2 * MAX_FRAME as soon as multiplexing is agreed: a data frame with
its envelope and MAC fits with room to spare, and a header over the cap
ends the tunnel before a byte of payload is read. TCP only; the
WebSocket and WebRTC codecs carry caps of their own.

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

* port forward: a channel id still live when the counter comes round is skipped

The controller handed out `next_id` unchecked. 2^32 opens later it lands
on a channel still up: the entry here was replaced, while the peer,
which ignores an `open` for a live id, kept routing that id to the old
socket, so the new local connection's bytes went into the old target
connection. The id is now taken under the map's lock and advanced past
any id in use.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:59:51 +08:00
fufesou
618bf37deb feat(terminal): add opt-in OSC 52 clipboard writes (#16072)
* feat(terminal): add opt-in OSC 52 clipboard writes

* Remove dup tr

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

---------

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

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


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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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


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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-04 15:49:50 +08:00
Daniel Nylander
b6ff62c74b l10n: fill remaining Swedish entries (#16050)
Signed-off-by: Daniel Nylander <github@danielnylander.se>
2026-09-04 15:26:21 +08:00
fufesou
ba6de7990f fix(ci): ubuntu-22.04-arm, oom (#16056)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-04 15:16:33 +08:00
Joss Gray
a59ad333fc fix: delimit FFmpeg pkg-config option (#16055) 2026-09-04 15:16:00 +08:00
fufesou
82aa28f129 fix(ci): install CMake 4.3 for ARM64 vcpkg builds (#16044)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-03 19:28:54 +08:00
Mariano Abad
3f93005be2 fix(drm): deliver a rotated output upright (#15886) (#15889)
* fix(drm): deliver a rotated output upright instead of sideways (#15886)

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

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

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

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

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

* refactor: trim comment density to the file norm

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

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

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

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

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

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

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

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

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

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

adversarial pass over the previous commit, three structural findings.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: bump hbb_common to main tip

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

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

---------

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

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

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

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

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

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

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

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

* ci: publish msi templates for custom client builds

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* ci: build the msi template with a sentinel revision

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(portable): retry failed stale branding cleanup

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

* fix(portable): reject malformed RDPKG resources

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

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

* refact: format 2 files

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

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

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

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

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

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

* fix: generate.py, exclude-exe

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

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

This reverts commit 5104664e95.

* fix: simple path fix in generate.py

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

* Remove useless comments

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

* fix(portable): remove expect() anyway

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

* fix(portable): validate executable path boundaries

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

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

* fix, remove useless file

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

---------

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

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

* Update tw.rs

* Update tw.rs
2026-09-02 14:36:49 +08:00
palmoni5
f28ac38ccf feat: optionally sync clipboard between connected sessions (#15934)
* feat(clipboard): optionally sync clipboard between connected sessions

Clipboard content received from a remote session is written to the local
clipboard with an owner marker, so the client clipboard loop deliberately
skips re-broadcasting it to avoid echo loops. As a result, text copied in
one remote window could not be pasted in another connected remote window.

Add an opt-in local option (allow-sync-clipboard-between-sessions) that
relays Clipboard/MultiClipboards messages received from one session to
all other connected sessions, excluding the source session. Per-session
clipboard permissions and view-only mode are still respected via the
existing send path, and the owner marker on the receiving peers prevents
any echo back.

Desktop (flutter) only; file clipboard is not affected.

* fix(lang): propagate sync-clipboard-between-sessions-tip to all locale files

Add the new key to template.rs and every locale file per the localization
convention, move the en.rs entry to the end of the list, and drop comments
that only restated the names next to them.

* fix(lang): add the 'Sync clipboard between sessions' label to the localization catalog

The checkbox label goes through translate(), so add it to template.rs
and every locale file so non-English locales can translate it. en.rs is
skipped since the English display text is identical to the key.

* fix(clipboard): check the source session's full clipboard permission before relaying

The relay was gated only by the incoming clipboard_allowed check
(!disable_clipboard && !view_only). Gate it with
is_text_clipboard_required() instead, which additionally respects the
source session's server_clipboard_enabled and server_keyboard_enabled
state, matching the predicate already applied to destination sessions.
A message arriving after the source permission was revoked (or from a
non-conforming peer) is no longer propagated to other sessions. The
existing local update_clipboard behavior is unchanged.

* fix(lang): translate the new clipboard sync entries in all locale files

Fill the 'Sync clipboard between sessions' label and its tooltip in
every locale file instead of leaving them blank, following each file's
existing terminology. template.rs keeps the empty master entries.
2026-09-01 10:47:26 +08:00
rustdesk
28cf1836e6 bump to 1.5.0 2026-09-01 08:57:33 +08:00
Michael Clark
1ec1b9e7e3 fix: android: target API 36 (#15603)
* fix: android: target API 35

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: handle API 35 foreground service types

Integrate the foreground-service and MediaProjection lifecycle changes
from fufesou/rustdesk#68 while leaving storage permission handling to
#15602.

Co-authored-by: fufesou <linlong1266@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: bump required android sdk version to 36, per recent google requirement change.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix(android): clear microphone FGS type when capture stops

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

* fix(android): harden API 36 capture service lifecycle

- isolate MediaProjection callbacks per session
- keep foreground service types in sync with capture state
- handle audio startup failures and shared frame ownership
- upgrade AGP to 8.10.1 for API 36 support

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

* fix(android): reset capture state on FGS update failure

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

* fix(android): recover capture after projection failure

Propagate virtual display startup failures, clean up partial video
resources, and resume capture after media projection is reauthorized.

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

* fix(android): preserve voice call during projection replacement

Keep the existing capture active until
a new projection is acquired, and restore the
voice-call audio source when capture restarts.

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

* fix(android): use JDK 17 in playground workflow

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

* fix(android): clear pending capture restart on denial

Notify MainService when a recovery projection
request is canceled so a later projection grant
cannot restart stale capture state.

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

* fix(android): handle audio and projection recovery failures

Verify AudioRecord startup, propagate voice-call restoration failures,
and clear stale capture recovery state when projection setup fails.

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

---------

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-31 22:29:51 +08:00
rustdesk
2c84c8fb13 change to 3.44.9 flutter for arm 2026-08-31 19:06:32 +08:00
RustDesk
66ab0b87f6 Linux drop shell from service loop (#15979)
* perf(linux): stop the service loop from forking a shell per environment variable

The service loop re-derives the desktop every 500 ms, and every lookup on that
path forks. A healthy GNOME session spends ~104 process spawns a second, 8 full
`ps -u <uid>` scans and 2 full `ps aux` scans, to re-answer a question whose
answer has not changed. `get_env` alone is a `sh -c` pipeline of ~12 processes
per variable.

`get_envs` already reads `/proc` directly and was documented as the intended
replacement, so move the remaining `get_env` callers to it and delete it. The
xwayland probe drops from 4 pipelines (~48 processes) to one `/proc` walk, and
the pathological walk that #15952 was about drops from ~2900 processes to at
most 60 `/proc` walks. `get_cm` and `is_xwayland_running` read `/proc` instead
of forking `ps aux` and `pgrep -a`; `get_cm` also called `current_exe()` once
per line of `ps` output.

Selection semantics are preserved where they were load-bearing:

* `get_envs_of_newest` reproduces the `ps ... | tail -1` the removed pipelines
  used, so a variable the newest matching process does not have means moving on
  to the next pattern, never on to an older process that may belong to a session
  which has since logged out.
* `get_envs` keeps its own order (readdir) and its all-process ranking, so the
  existing `get_display_xauth_wayland` caller is unaffected. Only its handling
  of an exported-but-empty value changes: `DISPLAY=` no longer counts as found,
  where it used to satisfy a single-name query and return the empty value before
  a process holding a real one was examined.
* `get_envs_where` lets the caller state what a complete answer is. Ranking by
  how many of the requested names a process carries cannot know that `DISPLAY`
  is mandatory and the rest interchangeable, so it could rank a process holding
  three optional values above the one holding the pair that matters.

`is_xwayland_running` is scoped to the session's uid. The compositor starts
Xwayland as the session user, so another user's Xwayland -- a switched-away
session, a second seat -- used to route a pure-Wayland session into the Xwayland
probe, which has no display for it to find there.

Not addressed: this discovery path has never had any notion of the active
session, and filters by uid alone. Constraining candidates to the active session
is not possible for the most important one, since `xdg-desktop-portal` and its
backends run under `user@<uid>.service`, which spans sessions and carries no
`XDG_SESSION_ID`, no session cgroup and no audit sessionid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5egQpH4q4GoXJiuMoTJ5t

* fix(linux): the newest-process walk must not answer with a grep or an older PID

Three findings from review of the commit before this one.

`/proc/<pid>/environ` failing to read left the walk on to the next PID, which in
`newest_first` mode is an older process -- possibly of a session that has since
logged out -- where the `ps ... | tail -1` pipeline this replaces stopped at the
one PID it had already picked. A read that fails is a process carrying none of
the requested names, not a process to skip. The `seen` latch that was meant to
hold the newest process is deleted: `accept` is reached once per matching
process, so returning on the first is what it already did.

The regex is matched against the whole `/proc/<pid>/cmdline`, where the pipeline
had a `grep -v 'grep'`. A user running `grep Xwayland` is otherwise the newest
match for that pattern and answers with whatever environment their shell had --
an X forwarding endpoint over ssh, say. This is the one place the walk still
differs from the `get_envs` it grew out of, which never had that filter and
could take an ssh `grep` over the portal it was looking for.

`get_envs` is left exactly as it was. Its completeness test was every requested
name *present*; stating it through `accept` turned it into every name *non-empty*
and, with the empty-value change that went with it, moved which process the
existing `get_display_xauth_wayland` caller settles on. `accept` is now told the
count and asks the question the loop it replaced asked. This supersedes the
`get_envs` bullet of the previous commit message: an exported-but-empty value
counts as found again, as it always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QgsYAUYKDei1AM5yHJsMX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:51:27 +08:00
rustdesk
169f74f8d9 fix(ci): check out submodules in update-webpki-roots
The root workspace lists libs/hbb_common as a member, so without the
submodule cargo cannot load the workspace and `cargo update` exits 101.
The job has failed on every scheduled run since it was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gecc6fgEeSxs6VRiQmAeof
2026-08-31 17:31:57 +08:00
Michael Clark
d4b06a6c5c fix: android: replace all-files access with scoped storage (#15602)
* fix: android: replace all-files access with scoped storage + system picker

Remove MANAGE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE, and
WRITE_EXTERNAL_STORAGE from the Android manifest. Remove
requestLegacyExternalStorage. Replace broad external storage with
app-scoped external storage for the file-transfer workspace.

File import uses the system file_picker. File export uses Android's
SAF ACTION_CREATE_DOCUMENT with path validation that restricts
export sources to app-owned directories.

Remove the external_path dependency.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: refine file import feedback

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: use SAF for file imports

Replace file_picker imports with Android's Storage Access Framework to avoid legacy storage permissions, stale cached files, and duplicate staging of large imports. Stream selected documents into app-scoped storage with failure-safe replacement, keep exports restricted to validated app storage roots, use filesDir for the internal fallback workspace, and remove legacy permissions contributed during manifest merging.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: keep file imports in the selected directory

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: reset projection and constrain file workspace

Release capture resources when media projection is revoked externally. Keep Android local file navigation within the app-scoped workspace.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: handle scoped storage start-up regressions. Allow zero digits in POSIX filenames by rejecting NUL explicitly, and initialise the app-specific home directory before the Android service starts the native server.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: update content resolver mode to use 'wt' instead of 'w' to prevent trailing bytes from old document whilst reporting sucess

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android, enforce file workspace boundary on the server, and unblock the ui thread.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: validate rename destinations against the app workspace bound file-operation paths. report rename failures, general import failures, and unregister / reregister projection when its onStop callback fires.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: reconnect was refreshing the directory with net entry instances, while selected items retained the old instances, it was reporting a selected item, but checkbox statue used object identity, and appeared unchecked. Fixed by reconciling by path and entry type before replacing the directory snapshot, rebinding valid selections, and dropping missing ones.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: (android) add SAF folder import and multi item export - import directories using ACTION_OPEN_DOCUMENT_TREE. Export multiple files, logs, and screen recordings via export buttons, add localisation keys for new actions

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix(android): harden scoped storage file handling

- create new SAF documents instead of overwriting export sources
- reject empty peer paths except for home directory reads
- report directory backup restore and cleanup failures
- resolve log export paths from the configured app name

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

* fix(android): harden scoped-storage file operations

- snapshot directory exports before writing to the destination
- query document provider metadata off the main thread
- reject invalid remote directories without read timeouts

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

* fix(android): handle SAF directory name collisions

- reject dot-segment folder names during import
- fail imports with duplicate document display names
- only reuse matching directories during export

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

* fix(android): handle SAF folder import collisions

Reject filesystem-equivalent destination names and
avoid showing a failure when folder overwrite is skipped.

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

---------

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-31 16:29:51 +08:00
fufesou
03a7fc5992 fix(flutter): align terminal shortcuts with platform conventions (#15970)
* fix(flutter): align terminal shortcuts with platform conventions

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

* fix(flutter): handle Linux terminal paste with modifier locks

Detect platform-specific paste shortcuts so Ctrl+Shift+V bypasses
virtual Ctrl/Alt modifiers on Linux. Add regression coverage.

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-27 16:33:58 +08:00
RustDesk
1fe451c2e8 chore(flutter): bump desktop_multi_window for show recovery (#15959)
Pick up rustdesk-org/rustdesk_desktop_multi_window#37, which re-arms the existing bounded redraw timer whenever a secondary window is shown, including when its first frame was generated while hidden but not presented.

This may perform one delayed child refresh on each show. It intentionally does not add a presentation-complete flag: Flutter reports frame generation rather than successful presentation, so recording success after a synthetic refresh could suppress later self-recovery without a reliable success signal.
2026-08-27 14:42:09 +08:00
fufesou
0b08a83d4b fix(file-transfer): improve large directory loading (#15830)
* fix(file-transfer): improve large directory loading

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

* fix(file-transfer): avoid failing newer directory reads

Track each remote directory request by its registered completer and only remove
the task when it still matches, preventing stale failures from affecting newer
requests for the same path.

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

* fix(file-transfer): handle slow directory listings safely

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

* fix(file transfer): correlate directory responses with requests

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

* fix(file transfer): prevent automatic directory responses from matching requests

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

* fix(file-transfer): handle large remote directory listings reliably

- build file rows lazily
- register remote reads before sending requests
- handle Home paths, stale responses, errors, and timeouts
- serialize same-path reads with different hidden-file options

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

* fix(file transfer): reduce diffs

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

* fix: build

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

* fix: invalidate pending dir reads on reconnect

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

* test(file-transfer): cover remote directory read lifecycle

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-27 13:01:11 +08:00
rustdesk
e9b81e3475 typo 2026-08-27 11:47:36 +08:00
RustDesk
7220f00410 fix(linux): a Wayland session without XAUTHORITY is not incomplete (#15978)
Fixes #15952.

Hyprland runs Xwayland without exporting `XAUTHORITY`, and
`get_display_xauth_xwayland` only returns once it has both `DISPLAY` and
`XAUTHORITY`. On such a session that condition is never met, so every refresh
runs the retry loop to the end: 10 rounds x 6 process patterns x 4 variables =
240 `get_env` calls, each a `sh -c` pipeline of ~12 processes starting with a
full `ps -u <uid> -f`. That is ~2900 fork/exec per refresh, and the service loop
repeats every 500 ms. The reporter measured a full core on a low-end laptop and
~60% of a core on a 13600KF.

The Wayland side answers for such a session, so accept `DISPLAY` together with
either `XAUTHORITY` or `WAYLAND_DISPLAY` + `DBUS_SESSION_BUS_ADDRESS`. The
portal answers on the first pattern, which ends the walk there, as it already
did on desktops that do export an xauth.

The loop also assigned all four variables unconditionally per pattern, so the
patterns that do not run on a given desktop blanked out what an earlier one had
answered with -- the portal's valid `DISPLAY=:1` included. That is why the
`--server` was then started with no `WAYLAND_DISPLAY` and no
`DBUS_SESSION_BUS_ADDRESS`. Candidates are now taken from one pattern as a whole
and ranked, so a later pattern replaces an earlier answer only by being better,
and a session that can only offer a compositor and a bus still keeps them.

A compositor that starts Xwayland on demand shows the same shape from the other
side: the portal came up before Xwayland did, so its environment carries a valid
`WAYLAND_DISPLAY` and `DBUS_SESSION_BUS_ADDRESS` but no `DISPLAY`, and no pattern
here may ever produce one. That pair alone is a session the child server can be
started against -- it is exactly what `get_display_xauth_wayland` returns on --
so it outranks a bare `DISPLAY` and ends the retrying, while the rest of the
round still looks for something that completes the session.

Not specific to the drm build: the function is not feature-gated, and the commit
the report points at does not touch it.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:39:59 +08:00
fufesou
fd471fcf02 fix: show speed in desktop file transfer status (#15980)
* fix: show speed in desktop file transfer status

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

* fix: move file transfer speed beside progress bar

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

* fix: move file transfer speed into progress bar

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

* fix: refine file transfer speed display

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

* fix: adapt file transfer progress text colors

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

* fix: reduce file transfer speed text weight

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-27 11:08:58 +08:00
Jade
7c6e661fcc fix(linux): Set AppIndicator ID for tray-icon (#15981)
* set static AppIndicator ID in tray-icon init

allows DEs, eg. KDE to 'remember' the user's configuration of tray hidden/unhidden. see: https://github.com/rustdesk/rustdesk/discussions/15208

Signed-off-by: Jade <5164609+gnosticJade@users.noreply.github.com>

* Update tray.rs

---------

Signed-off-by: Jade <5164609+gnosticJade@users.noreply.github.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-08-27 09:41:24 +08:00
Mariano Abad
3f207e91f6 fix(linux): a session logout should hand the peer to the login screen (#15905)
* fix(linux): a session logout should hand the peer to the login screen

Logging out closes every window in the session, the connection manager's
included, and its close handler kicks every peer with the reason a person
gets when they disconnect one by hand. That reason is the one thing the
client never retries on, so the remote session dies on a frozen frame
instead of reconnecting to the greeter that is already there.

The close carries nothing to tell the two apart: measured on KDE, the CM
receives no signal and logind still reports the session active at that
instant, and the server is killed within a few hundred ms either way, so
neither a state check nor a grace period can decide it. What is
distinguishable is the ACTION: disconnecting a peer is not the same event
as this window going away. So the window-close path now says so, and the
server ends the session without poisoning the retry; the Disconnect
button and the app's own close control keep kicking exactly as before.
Linux only, since that is where a logout closes the window.

Verified on plasma/sddm with a client attached: a logout now reconnects
to the greeter with no dialog, while closing the manager window still
shows Closed manually by the peer.

* fix(linux): close the tunnel too, and keep the web build compiling

Three seams the first pass missed. The web bridge is hand written, not
generated, so the new call needs its stub there or flutter build web
stops compiling - and that job is disabled in CI, so it would have gone
green. try_port_forward_loop is a second consumer of the same channel
and only knew Close, so a forwarded tunnel outlived the window it was
supposed to die with. And the variant had landed inside the DRM section,
whose comment says everything below it is drm-gated.
2026-08-26 18:26:26 +08:00
Kino
cec4085238 Bump aom to v3.14.1 (#15883)
* Bump aom to v3.14.1

* Remove oboe dependency in vcpkg.json
2026-08-25 19:53:56 +08:00
fufesou
0d917c6fa1 fix: remove dup translations (#15967)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-25 18:25:49 +08:00
Rafli Surya Wijaya
893dc27798 docs(readme): fix broken Screenshots section anchor link (#15964) 2026-08-25 11:21:34 +08:00
Abdullah Kaleem
7cc82c1575 Add Urdu language support for UI strings (#15961)
* Add Urdu language support for UI strings till 329 line

Co-authored-by: Copilot <copilot@github.com>

* Add Urdu translations for additional UI strings

* Add Urdu language support in lang.rs

* Fix Urdu translations and remove unused keys in ur.rs

---------

Co-authored-by: Copilot <copilot@github.com>
2026-08-25 09:19:37 +08:00
jhertel
f07b6e2338 Correct Danish spelling, language and translation (#15943)
* Update da.rs

Corrected spelling, language and translation mistakes.

* Update da.rs

Missed one correction.
2026-08-24 17:22:03 +08:00
Robert Markovski
a3bab27a2a fix: Show My Cursor freezes in View Only mode when remote user mo... (#15936) 2026-08-24 17:21:11 +08:00
RustDesk
7423dced37 Update reference from AGENTS.md to @AGENTS.md 2026-08-22 17:50:10 +08:00
fufesou
a7deef02a2 fix(msi): keep only native ProductCode uninstall entry (#15891)
* fix(msi): keep only native ProductCode uninstall entry

Move installer state outside the Uninstall registry path,
clean up legacy duplicate entries, and use the MSI ProductCode
for updates and uninstalling.

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

* fix(msi): harden update and uninstall handling

- handle legacy EXE updates without an MSI ProductCode
- propagate MsiExec uninstall failures
- validate and XML-quote custom ARP values

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

* fix(msi): validate registry state before update and uninstall

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

* fix(msi): pass WindowsInstaller state to elevated sequence

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

* fix(msi): block unsupported MSI-to-EXE upgrades

- resolve native MSI state and ProductCode safely
- suppress reboot while preserving MSI uninstall results
- publish the resolved ARP install location
- skip invalid unrelated MSI uninstall entries

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

* fix(msi): fail uninstall when ProductCode is missing

Prevent known MSI installations from falling back to
EXE cleanup when the ProductCode cannot be resolved.

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

* fix(msi): do not abort update on ARP version write failure

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-22 17:49:00 +08:00
rustdesk
d5a7f67999 fix appimage pixbuf crash 2026-08-22 12:25:35 +08:00
ben-leone
e266380ee9 fix(appimage): keep the XDG default data dirs on XDG_DATA_DIRS (#15938)
AppRun sets XDG_DATA_DIRS to
"$APPDIR/usr/local/share:$APPDIR/usr/share:$XDG_DATA_DIRS". When the host
leaves XDG_DATA_DIRS unset, the result contains no /usr/share, and setting
the variable at all suppresses the XDG default of /usr/local/share:/usr/share.

gdk-pixbuf 2.43+ (Arch, CachyOS, Gentoo, Fedora, openSUSE) no longer ships PNG,
JPEG or WebP as loader modules; libgdk_pixbuf links libglycin and decodes them
through it, and glycin discovers its loaders in
$XDG_DATA_DIRS/glycin-loaders/<ver>/conf.d/*.conf. With /usr/share missing,
glycin finds none and every PNG decode inside the AppImage fails with
"Unrecognized image file format".

RustDesk sends remote cursors to flutter_custom_cursor as PNG, and that plugin
returns nullptr from a std::string function when the decode fails, so the first
non-default cursor of a session aborts the process:

    GdkPixbuf-CRITICAL **: gdk_pixbuf_copy: assertion 'GDK_IS_PIXBUF (pixbuf)' failed
    terminate called after throwing an instance of 'std::logic_error'
      what():  basic_string::_M_construct null not valid

Debian and Ubuntu compile PNG straight into libgdk_pixbuf and never reach
glycin, which is why this only affects non-Debian hosts.

Append the two XDG defaults so they are present when the host does not provide
them. They go last, so a session that sets XDG_DATA_DIRS properly keeps its own
precedence, and appending is a no-op where those paths are already listed.

Verified on CachyOS (gdk-pixbuf 2.44.7) against a stock 1.4.9 AppImage: with
only this variable changed, a full remote session runs without crashing and
renders remote cursors correctly.

Refs #4565 #5457 #7013 #9164 #10563 #11499 #12257 #14305 #14405 #15625

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:21:48 +08:00
Saverio Miroddi
cbf9440281 Prefer active X11 session display (#15933)
* Prefer active X11 session display

* Update linux.rs

* fix(linux): keep the logind display only when it is a local one

`get_display_from_session` returns the value pam_systemd was handed at session
creation, and logind never updates it afterwards. That value is not always a
usable local display: it can be qualified with this host (`myhost:0`), name an
X forwarding endpoint (`localhost:10.0`), or be a bare `:`.

Taking it unconditionally is worse than taking nothing, because a non-empty
`self.display` suppresses every fallback below it, `get_display_by_user` and the
`:0` default alike. The stripping at the end of `get_display_x11` does not save
the last two cases either: it leaves `:` as is and turns `localhost:10.0` into a
local looking `:10.0`, either of which is then exported as DISPLAY and leaves the
session unreachable, where before this PR the host got a working `:0`.

Strip this host so `myhost:0` is still accepted as `:0`, leave `localhost` in
place, and require a display number after the colon. Anything else falls through
to the existing chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKJxvTT6NQDEcnkWBx5bLA

* docs(agents): prefer a little duplication over a restructure

The "Be minimally invasive" rules already ask for purely additive diffs, but not
in the case where the addition would otherwise reshape an existing function so
the two can share code. Repeating a few lines is the better diff there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKJxvTT6NQDEcnkWBx5bLA

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:28:12 +08:00
rustdesk
6eaac17ac5 typo 2026-08-22 00:02:34 +08:00
fufesou
92eb137178 feat(terminal): use platform-native copy and paste shortcuts (#15931) 2026-08-21 21:20:39 +08:00
palmoni5
61ddade049 fix(windows): restore keyboard focus when the cursor re-enters the remote image (#15880)
* fix(windows): restore keyboard focus when the cursor re-enters the remote image

On Windows the raw key focus node is unfocused on window blur and nothing
requests it back, so returning to an already connected session left the
keyboard dead until the remote image was clicked.

Request focus from enterView(), gated on the window being active, the tab
being selected and no blocking overlay, so a background window cannot grab
system keys. enterOrLeave(true) is still driven by RawKeyFocusScope's
onFocusChange, so it is not called twice.

* fix(windows): refocus on window focus when the cursor already hovers the image

Alt+Tab or a taskbar click returns focus without a PointerEnter, so
enterView() cannot restore the keyboard. Reuse _cursorOverImage, gated
on the selected tab and no blocking overlay.

* refactor(windows): share one focus predicate for every requestFocus path

The relative-mouse-mode restore on window focus could hand remote input
to this page while a blocking dialog was up or the tab was not selected.
2026-08-21 17:07:25 +08:00
fufesou
c78bdefc44 fix: dialog, trackpad speed, buttons (close -> ok, cancel) (#15918)
* fix: dialog, trackpad speed, buttons (close -> ok, cancel)

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

* fix(flutter): handle trackpad speed dialog submission

- commit typed values from Enter and OK
- validate input before saving
- prevent duplicate submissions
- surface save failures

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

* fix(flutter): sync trackpad speed input and slider

- handle trackpad speed submission from IME actions
- update the slider when a valid speed is typed
- cover Enter, OK, IME, and invalid input behavior

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-21 14:15:35 +08:00
rustdesk
c45f7d2dd2 refactor is_public 2026-08-21 01:25:51 +08:00
RustDesk
798b73beb1 Update common.rs (#15924) 2026-08-21 00:52:31 +08:00
rustdesk
f1a06f6765 review rules 2026-08-20 19:22:36 +08:00
fufesou
0a4b431ea2 fix: correct terminal mouse selection and scroll coordinates (#15915)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-20 13:10:45 +08:00
21pages
5679670506 fix(flutter): make Adjust Window reliable across desktop platforms (#15853)
* fix(flutter): make Adjust Window reliable across desktop platforms

  - Fix incorrect sizing on scaled displays by calculating the target from the
    rendered canvas scale and platform-specific window coordinate units.
  - Fix adjustments using the wrong monitor by querying the current remote
    window's screen, with the main window as fallback.
  - Fix stale geometry after fullscreen or maximized transitions by refreshing
    metrics before calculating and applying the target frame.
  - Fix fullscreen availability checks on Windows and macOS by predicting the
    restored window borders and caching each macOS window's pre-fullscreen work area.
  - Fix incorrect Linux work areas by handling GNOME Wayland fractional scaling
    and caching compositor/X11 work-area measurements when visibleFrame is wrong.
  - Prevent unsafe adjustments by rejecting invalid, oversized, or implausibly
    small target frames.
  - Avoid failures during window teardown by skipping adjustment when the view,
    screen, or native window frame is unavailable.

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

* fix(flutter): harden Adjust Window handling

  - Use the dynamic Linux resize edge when predicting restored window bounds.
  - Treat GNOME fractional-scaling lookup failures as unknown without repeating
    the lookup for the remote window.
  - Stop adjustment safely when native window calls fail during window teardown.

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

* fix(flutter): correct Linux monitor selection

Update window_size to use monitor height for vertical bounds, preventing incorrect screen selection with vertically stacked displays.

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

* docs(flutter): simplify Linux screen handling comments

Keep the source rationale concise and move platform measurements and investigation details out of the implementation.

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

* fix(flutter): align Adjust Window resize padding

Use the shared drag-to-resize padding for Linux restored-window predictions so menu validation matches the applied frame dimensions.

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

* fix(flutter): remove Adjust Window screen fallback

Return null when the current window screen is unavailable instead of using the main window's scale factor and work area.

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

* fix(linux): query Mutter monitor layout mode

  Use DisplayConfig.GetCurrentState instead of inferring scaling from
  experimental features, and handle Ubuntu's UI-scaled logical mode.

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

* fix(flutter): use native maximized state for Wayland cache

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

* fix(flutter): allow Adjust Window to fill work area

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

* fix(flutter): avoid racing screen info updates

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

* refactor(flutter): remove dead Adjust Window web plumbing

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

* fix(flutter): tolerate near-unity Wayland scale factors

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

* fix(flutter): harden window screen detection

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

* fix(linux): drop deprecated GNOME session detection

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

* fix(flutter): remove GNOME monitor layout mode flutter cache

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-08-19 15:08:58 +08:00
Mariano Abad
630b531108 fix(flutter): initialize the cursor hotspot y from its own origin (#15898)
The CursorData constructor copies hotxOrigin into hoty. Latent today:
both consumers call updateGetKey() before reading, and _checkUpdateScale
recomputes hoty from hotyOrigin - but any future read before that call
inherits the x value silently.
2026-08-19 12:49:28 +08:00
RustDesk
1984678785 Hide printer tab when settings disabled (#15901)
* fix: hide the printer settings tab when settings are disabled

The Security and Network tabs already honour `disable-settings`, but the
Printer tab was gated only on `hide-remote-printer-settings`, so custom
clients built with settings disabled still exposed it.

https://github.com/rustdesk/rustdesk-server-pro/issues/1001

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

* feat: add hide-general-settings builtin option

Hides the General tab of the settings page. Unlike the other
hide-*-settings options this one is still useful when settings are
disabled, since `disable-settings` does not cover the General tab.

https://github.com/rustdesk/rustdesk-server-pro/issues/1001

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:48:30 +08:00
fufesou
b0008edcb5 refact: remove linux headless (#15866)
* refact: remove linux headless

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

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

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

* revert changes in drm_capturer.rs

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

* Update submodule hbb_common

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

* docs(linux): clarify DRM availability comments

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

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

* fix(linux): remove unreachable session cleanup branch

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

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-18 15:02:50 +08:00
rustdesk
0c00d576dd improve comment rules 2026-08-18 12:31:59 +08:00
RustDesk
9b1b810d3a Delete .github/dependabot.yml (#15888) 2026-08-18 10:40:04 +08:00
RustDesk
6a27910f34 fix(wayland): back off the polling display lookups after a failure (drm) (#15865)
* fix(wayland): back off the polling display lookups after a failure (drm)

In drm builds an enumeration that fails with no endpoint named in the
environment falls back to the socket probe, which forks a child bounded by
seconds, and the display service asks again every 300 ms -- at a greeter
with no reachable compositor that is a probe child per turn, forever. Such
a failure now stamps a shared 5 s backoff, and only the polling callers
honor it: the 300 ms displays-changed check skips its turn and the 1.5 s
live layout poll returns no answer for that turn.

Only the failure that would fork stamps. A session server is spawned with
WAYLAND_DISPLAY set, so its failed connect bails in-process before any
fork; stamping there would buy nothing and cost recovery latency, so live
sessions keep master's behavior exactly. The stamp also survives
clear_wayland_displays_cache: it describes the seat, not the cache, and
the ~1/s capturer rebuild loop clears on every teardown -- dropping the
stamp with the cache would let that loop defeat the backoff and would
turn every post-hotplug failure into a "first" one forever.

The displays-changed check weighs the backoff against what is already
published. With nothing synced yet it always populates -- an unaugmented
DRM list beats the empty broadcast the send path would otherwise emit.
With a synced layout, a suppressed turn keeps it, and a fresh first
failure keeps it too; only a failure that persists across a backoff
replaces it with the DRM stack, so a hotplug at a failing seat converges
within one backoff while a transient failure never tears down a good
layout.

One-shot callers -- session init, pipewire stream setup, capturer info --
keep probing fresh through get_displays, whose failure semantics are
unchanged: replaying a transient failure there would latch an empty answer
into session-long state. Non-drm builds compile none of this.

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

* fix(wayland): log DRM lookup failure once

* fix(wayland): reset lookup warning after recovery

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:57:06 +08:00
rustdesk
14a4b197ad translations 2026-08-17 17:22:57 +08:00
Krik JIN
8ffe3117a5 feat(flutter): add mobile canvas lock (#15877)
* feat: add mobile canvas lock

* Update flutter/lib/models/model.dart

Remove redundant canvas-lock comment

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

* remove redundant logic

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Krik Jin <isjinhk@outlook.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-17 17:04:58 +08:00
RustDesk
5a78be03e3 feat(rdp): title the mstsc window after the peer instead of "localhost" (#15781)
* feat(rdp): title the mstsc window after the peer instead of "localhost"

The RDP tunnel launched `mstsc /v:localhost:<port>`, so with several
sessions open every window is titled "localhost" and servers cannot be
told apart.

mstsc titles the session window after the launched .rdp file's base
name, so write a temp .rdp file (containing only the tunnel address)
named after the peer alias, cached hostname, or id, and launch that
instead. Falls back to the old /v: form when no usable name remains
after filename sanitization or the file cannot be written. Credential
handling is unchanged: cmdkey targets "localhost", which is still the
host mstsc resolves credentials against.

Fixes rustdesk/rustdesk#15775 (discussion)

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

* fix(rdp): set mstsc title without temporary files

  Keep launching mstsc with /v so Default.rdp settings are preserved
  and unsigned RDP file warnings and policy restrictions are avoided.

  Track the launched mstsc process and reapply the peer name when the
  window title is reset during connection or reconnection.

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

* docs(rdp): clarify mstsc title limitation

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

* feat(rdp): show peer identity with hostname in mstsc title

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-08-17 13:30:18 +08:00
fufesou
edd0e5fbd4 fix(CI): rust 1.75, linux sciter (#15874)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-17 09:30:19 +08:00
yzxcj797
3871c47855 docs: remove stale flutter/web/js entry and fix dead localized build links (#15869) 2026-08-17 09:18:45 +08:00
yzxcj797
8d52d48b24 docs: fix dead code of conduct links in ID/IT contributing guides (#15868) 2026-08-17 09:18:21 +08:00
RustDesk
7aa98d43cf Refact/plugin removal leftovers (#15864)
* fix(flutter): dispose the settings PageController and order dispose() correctly

`dispose()` began with `super.dispose()`, so the mixin chain marked the State
defunct before the WidgetsBindingObserver registration and the periodic timer
were released. The `PageController` was never disposed at all: `Get.delete`
only runs `onDelete()` for a `GetLifeCycleBase`, and a plain `ChangeNotifier`
is not one, so every open/close of the Settings tab leaked one controller with
its listener still attached.

Also guard `switch2page` on the `Rx<SettingsTabKey>` registration it actually
reads rather than only the `PageController` — now that both are really
deleted, a partial teardown would throw into the catch and silently open the
wrong tab — and re-check `mounted` after the await in the `_videoConnTimer`
tick, which `Timer::cancel` cannot stop once the body has started.

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

* refact: finish the plugin-framework removal sweep

#15854 removed the feature but stopped short of its leftovers:

- `Uninstall`, `Enable`, `Disable`, `Options` and `Please install plugins`
  were consumed only by the deleted `flutter/lib/plugin/**`; drop them from
  template.rs and the 50 locale files (250 dead entries). `Update` and
  `Install` stay, still used by desktop_home_page.dart.
- The server no longer sends `PrvOnFailedPlugin`, and the client no longer
  offers to install plugins when privacy mode fails to turn on.
- Drop the MSI `F_Client_Plugins` / `F_Server_Plugins` localization strings;
  no `.wxs` references them.
- `_DisplayMenu`'s constructor became a pure pass-through once `pluginItem`
  was removed, and the cfg inside `handle_input` repeats the one on the
  function itself.
- Normalize `src/lang/sl.rs` to 0644, the only executable file under src/.

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

* fix(client): handle legacy privacy mode plugin failures

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-14 18:52:03 +08:00
fufesou
d1da05c4db refact: remove feature plugin-framework (#15854)
* refact: remove feature plugin-framework

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

* refact: remove unused translations

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

* fix: delete settings tab observable with correct type

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-14 14:31:13 +08:00
Mariano Abad
d829d1410a fix(linux): serve the Wayland login screen the DRM backend was built for (#15792)
* fix(linux): serve the Wayland login screen the DRM backend was built for

The login screen support in #15420 never worked on a real greeter. fufesou found
it: the session is refused, and with the refusal commented out the client gets a
failed connection instead of a screen.

One premise under all of it. `get_values_of_seat0` is
`_get_values_of_seat0(.., ignore_gdm_wayland = true)`, so a gdm/sddm Wayland
session is skipped by construction and `get_display_server` falls back to x11.
That was correct while the portal was the only backend, since the portal cannot
serve a greeter at all. The DRM path never talks to the compositor, which is
precisely why it can serve one, so the premise stops holding there and every
x11-vs-Wayland decision in the tree answers x11 at a login screen.

The central change is the memoised `IS_X11`: when it reads x11 and seat0 is a
Wayland greeter, answer Wayland. That covers fifteen routing sites at once, and
it is under `cfg(feature = "drm")`, so a build without the backend keeps the
current answer exactly. `is_x11_for_drm` is the unmemoised form for the two
retry loops that must keep asking while a boot is still naming the session, and
the memoised accessor is scoped to per-frame callers in the per-session
`--server`, which the service only spawns once it has identified the session.

Input was the last layer and lived outside all of that. `Enigo` decides
x11-vs-Wayland once in `Default::default()`, from the same seat0 lookup, and on
"x11" routes every key and mouse event to xdo; with no X server that context is
null and libxdo drops them without an error. So the uinput devices were created,
the compositor opened them, and nothing was ever written to them. `set_is_x11`
is now called where the custom devices are installed, which is only reached once
`!is_x11()` is already established. The unit test pins both directions, since a
one-directional test passes against the bug.

With no compositor reachable, the uinput desktop rect comes from the DRM display
list instead: those are the same displays being captured, so the coordinate space
matches by construction. Telling the truth about a greeter also makes four
compositor-probing paths reachable where the probe cannot answer; all four
already treat an empty output list as "nothing to do", so they skip it and 11818
"Could not find wayland compositor" warnings in one session became 1.

Tested on an sddm Plasma Wayland greeter, MacBook T2, 2880x1800: the greeter
renders, typing from the client enters characters in the password field, a click
at an absolute coordinate opens the greeter session combo, the service pre-warm
primes in 994 us instead of timing out, and the privileged service maps no EGL
during a live capture. Not proven on gdm under Wayland.

Known limitations: non-ASCII characters cannot be typed at a greeter, because
that path goes through the clipboard and the clipboard here is X11 only; and at
a multi-monitor greeter the pointer reaches the first display only, since every
DRM output reports origin (0,0) on Wayland and there is no arrangement to derive
without the compositor.

* fix(linux): a Wayland greeter the DRM backend can serve is not headless

fufesou reported the login screen still failing on Ubuntu 24.04 with gdm3, with
the client asking for OS credentials to start an X session instead of showing the
greeter. Reproduced on a real gdm greeter here.

Same premise as the rest of the branch, one more consumer. `DesktopManager::new`
reads seat0 through `get_values_of_seat0`, which skips a gdm/sddm Wayland session
by construction, so at a greeter it finds no session at all and
`get_supported_display_seat0_username` returns None from its empty-username arm.
That makes `is_headless()` true, so the service advertises headless and
`try_start_desktop` answers `LOGIN_MSG_DESKTOP_SESSION_NOT_READY`. The corrected
`IS_X11` does not reach this one: it asks who owns seat0, not which display
server is running.

So ask again, with the greeter visible, when the DRM backend can capture and
inject into it. At query time rather than in `new()`, because the DRM probe has
not necessarily settled when the desktop manager is constructed, and the answer
would latch for the process lifetime. In a normal session the latched username is
a real user and the extra read is skipped.

* chore: drop the hbb_common bump, this branch does not need it

The bump carried rustdesk/hbb_common#580, the compositor-socket fallback. Nothing
here depends on it: the greeter paths in this branch are the ones that run when
compositor data is unavailable, which is what the commit before this one states as
a known limitation. Keeping the bump would only block the greeter fix behind a
review of a separate change, and would import that change's blocking review items
into this path.

* fix(linux): let the uinput uid gate see the greeter that owns seat0

Input at a real greeter was rejected by our own authorization. Measured on Ubuntu
24.04 with gdm3: the root service logs

  Rejected unauthorized connection on uinput ipc channel:
  postfix=_uinput_control, peer_uid=Some(120), active_uid=None

and the greeter's `--server` gets ECONNRESET out of `setup_uinput`, so no uinput
device is ever created and neither keyboard nor mouse reaches the greeter.

uid 120 is gdm, the owner of the only active seat0 session. `active_uid` is None
because the uinput authorizer deliberately bypasses the service-loop cache and
takes a fresh seat0 lookup, and the fresh read hides a Wayland greeter by
construction. The cache-based gates do not have the problem: `Desktop::refresh`
fills it through the greeter-visible read, which is also why capture and config
sync work at a greeter while input does not.

So make the fresh read agree with the cache. It keeps the property the uinput gate
wants, a lookup that cannot be stale, and it still compares the peer against the
uid of the session that owns seat0 -- which at a greeter is the greeter.

* fix: settle the DRM probe before routing login to X11, and read seat0 fresh

Two findings from the #15792 review, both verified against the code:

- drm_login_screen_seat0_username asked the cached probe, so a client
  arriving before warm_availability publishes its verdict read "no DRM"
  and, with allow-linux-headless=Y, try_start_x_session could start Xorg
  over a live Wayland greeter. Ask the probing form instead, and only
  after the cheap seat0 read says a Wayland greeter is actually there: a
  bounded definitive verdict is affordable on a login-time path.

- get_supported_display_seat0_username trusted the seat0 values cached in
  DesktopManager::new(), which go stale across a logout or a fast user
  switch: a stale non-greeter name skipped the greeter probe and was
  returned as the supported display owner. Read seat0 fresh on every
  query; every call site is connection-time, so the extra loginctl read
  is cheap.

Regression-tested on a real sddm Wayland greeter: capture streams the
greeter, the RustDesk password dialog is the only prompt, and five typed
characters appeared in the greeter password field over uinput with zero
"Rejected unauthorized connection" lines in the service log.

* fix: ask the greeter compositor for the multi-monitor layout

The display arrangement and the pointer mapping were wrong at a
multi-monitor login screen, and the mechanism is measured on a two-head
virtio VM: DRM has no origins, so every display was advertised at (0,0)
(a stacked arrangement on the client), and the uinput range was taken
from the union of the DRM modes while the compositor had arranged the
outputs side by side.

Both came from the same premise, written before the hbb_common socket
fallback existed: "a login screen has no compositor to ask".
wayland_outputs_askable() skipped the wl_output augmentation at any
greeter, and update_uinput_resolution took the DRM union directly. The
premise is false now: a greeter runs a compositor, and the socket
fallback reaches it with no environment variables, measured answering
two outputs at the VM greeter while the old gate was still routing
around it.

Drop the gate and take the compositor-first path everywhere. Where the
fallback cannot answer, the output list comes back empty and both call
sites degrade to exactly the old behavior, so a build against an older
hbb_common is unchanged.

* fix: augment a single display too, and probe the desktop rect off the executor

Two follow-ups from the automated re-review of cd80c3dee, both verified:

- augment_with_wayland_geometry skipped the compositor below two DRM
  displays, but on a multi-GPU host the one connector this service can
  open may sit at a non-zero origin of the compositor layout, and DRM
  alone reports (0,0).

- the desktop rect for uinput can now block for the socket probe
  deadline, and update_uinput_resolution runs on current-thread
  runtimes; move the query into spawn_blocking.

The third re-review finding, the warm-up allegedly skipping Wayland
greeters, is refuted: warm_availability probes while is_x11_for_drm()
is false, which includes a Wayland greeter, and the greeter log of the
VM run behind cd80c3dee shows the warm succeeding there.

* fix: baseline the layout from the blocking task, and augment a lone output's origin

The layout snapshot after the rect lookup still ran on the executor: a
failed compositor lookup is not cached, so the snapshot synchronously
repeated the whole socket probe there. The baseline is now computed
inside the same blocking task, from the snapshot the successful lookup
just cached, or omitted when only the raw DRM union was available,
which keeps the #15601 remap inactive exactly where origins are
unknown.

A single compositor output now hands its origin to a single connector:
the lone output can sit at a non-zero origin the DRM side cannot see.
Scale stays 1 on purpose, matching how a single display is advertised
at physical size, and more connectors than the one output stays
unaugmented, since the layout-order fallback would plant that origin on
a guess.

Also refresh the get_primary_index doc that still said augmentation
declines below two connectors.

* fix: read the DRM probe as a tri-state, and keep pre-auth seat0 checks cache-only

is_available() answered false both for a definitive no-DRM verdict and
for a probe that had simply not settled (another probe in flight, or a
failure still below the disable threshold), and the login-screen
decision turned that transient false into no-greeter: try_start_x_session
could put Xorg over a live greeter in exactly the window the probe
needed. The machinery now answers Available/Unavailable/Unsettled, and
only a definitive Unavailable routes the seat toward X11.

Connection setup also ran the whole lookup pre-auth: constructing
LinuxHeadlessHandle called is_headless() before authentication, holding
DESKTOP_MANAGER while loginctl ran and, at a greeter, while the DRM
probe waited out its handshake. An unauthenticated peer could occupy a
worker for seconds and serialize every other connection on the mutex.
is_headless() now answers from a snapshot refreshed off-thread, and the
fresh lookup became a free function called with the manager lock
released everywhere; the enforcing decisions, get_username and
try_start_x_session, still read seat0 fresh.

Also drops seat0_display_server, dead since the fresh-read change.

* fix: respect RUSTDESK_FORCED_DISPLAY_SERVER over the greeter correction

The greeter correction rewired IS_X11 and is_x11_for_drm() to Wayland
whenever seat0 looks like a Wayland greeter, including when the operator
explicitly forced the display server: get_display_server() kept honoring
the override while the DRM routing gates contradicted it, leaving
capture and input routing internally inconsistent. The correction now
only adjusts the auto-detected answer.

* fix: honest pre-auth snapshot, sticky negative verdict, and a complete forced-x11 gate

Four defects found by an adversarial review of the two previous
commits, all in their new lines:

- The empty-snapshot fallback derived headless from the manager's
  boot-time seat0 read, which is blank at a Wayland greeter (the
  loginctl wrapper skips greeter sessions), so the first connection of
  every server process at a greeter answered headless=true, the
  opposite of the comment on it. No snapshot now answers NOT headless,
  the snapshot is seeded at start_xdesktop, and the boot-time cache is
  gone entirely (it had no reader left).

- wait_desktop_cm_ready gated on a bool stored at construction, which
  can lag one seat0 transition behind and skipped the CM-ready wait
  right after a logout. It re-reads the snapshot at call time.

- A settled Unavailable was erased at NEGATIVE_TTL expiry (state to
  Unknown, failure counter to zero), so a permanently helper-less box
  reopened the Unsettled window every 30 seconds and the login decision
  kept adopting a greeter nothing can serve. The verdict now stays
  Unavailable while an off-thread re-probe re-verifies it: a failed or
  empty re-probe restamps the no, and only a non-empty list flips it.

- The forced-x11 gate only covered IS_X11 and is_x11_for_drm, while
  the seat0 adoption path still probed DRM and admitted greeter
  sessions whose capture and input then routed to X11. Greeter
  adoption now yields to an operator-forced X11, degrading to upstream
  behavior: the connection is refused at the login screen.

* fix: keep the login request path off the probe entirely

try_start_desktop runs while handling a LoginRequest, before password
validation, and at a Wayland greeter its seat0 lookup reached the
probing availability form: an unauthenticated peer could park a worker
for the probe deadline. The greeter adoption now reads a cached
tri-state that never blocks; when the state is Unknown it kicks the
probe off-thread and answers Unsettled, which the login decision treats
as a possibly servable greeter until it settles. Settling lives in the
startup warm-up, that kick, and the TTL re-verifiers; the blocking form
stays for the capture-side callers, where waiting is acceptable.

* fix: run the pre-auth desktop start off the executor, guard the refresh flag, trim comments

From fufesou's #15792 re-review (no blocking issues) plus a bot pass:

- try_start_desktop now runs on spawn_blocking. It executes loginctl,
  and PAM when a session must start, while handling a LoginRequest
  before password validation, so a slow logind must not tie up an async
  request worker; the blocking pool absorbs it.

- kick_seat0_refresh releases SEAT0_REFRESH_IN_FLIGHT through an RAII
  guard, so a panic in the refresh thread cannot freeze is_headless on a
  stale snapshot for the process lifetime.

- drm_can_serve_login_screen stays Available-only, and the reason is now
  in the code: it is deliberately not symmetric with the seat0 adoption
  gate. Adoption yields Xorg only on a definitive Unavailable; admission
  accepts only on a definitive Available; both wait through an unsettled
  probe. Admitting there would black-screen a client on a helper-less
  box, so a review suggestion to make them agree is declined.

- Trimmed two over-long comments to the repo's three-line rule.

* fix(linux): harden DRM login-screen startup

Keep unauthenticated headless checks cache-only, bound OS-session startup to one blocking task, and surface JoinError failures.

Wire the isolated Wayland probe consumer and update hbb_common plus libdrmtap 0.5.4.

* fix(linux): headless refresh state

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

* fix(linux): keep headless startup state consistent

- gate concurrent desktop startup attempts
- route CM IPC after refreshing desktop state
- avoid blocking seat0 queries in the CM retry loop
- preserve newer seat0 snapshots during overlapping refreshes
- derive DRM geometry and primary display from one Wayland snapshot

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: rustdesk <info@rustdesk.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-13 20:22:41 +08:00
fufesou
c4fd7d692d refact: fuser 0.16.0, cargo 1.75.0 (#15844)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-12 21:36:06 +08:00
rustdesk
dfca2c1b8f update agents.md 2026-08-12 17:28:59 +08:00
RustDesk
10bcf976f7 Revert "fix: upgrade fuser to 0.16.0 (GHSA-cvmj-47v9-35m9) (#15834)" (#15841)
This reverts commit 63822048df.
2026-08-12 17:12:46 +08:00
Anupam Mediratta
63822048df fix: upgrade fuser to 0.16.0 (GHSA-cvmj-47v9-35m9) (#15834)
FUSE-Rust: Uninitalized memory read and leak caused by fuser crate
Resolves GHSA-cvmj-47v9-35m9

Signed-off-by: anupamme <mediratta@gmail.com>
2026-08-12 14:30:43 +08:00
fufesou
1d09760ef7 fix(terminal): keep selection aligned after clearing scrollback (#15831)
Remove scrollback lines through the index-aware buffer operation so
deleted anchors are detached and retained lines are reindexed.

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-11 15:54:03 +08:00
Chen, Ting-An
23256e6ac1 fix(i18n): complete Traditional Chinese sign-in strings (#15829)
Signed-off-by: 陳廷安 <73953029+nrps9909@users.noreply.github.com>
2026-08-11 14:00:05 +08:00
RustDesk
ff07ff7f13 fix(terminal): send SGR mouse wheel reports with the button codes app… (#15817)
* fix(terminal): send SGR mouse wheel reports with the button codes apps expect

xterm.dart 4.0.0 encodes the wheel buttons as 64+4..64+7 rather than
64+0..64+3, so the low bits land on the modifier field and every wheel
report the terminal emits reads as wheel-with-Shift. Strict full-screen
applications reject the modified event, which is why neither the mouse
wheel nor the trackpad scrolls anything once the peer application takes
over the alternate screen.

Install a mouse handler that keeps every upstream reporting decision and
only re-encodes the wheel buttons as 64..67. Non-wheel reports pass
through untouched, and the emitted bytes stay identical once upstream
ships the same fix, so this can be dropped without a behavior change.

Upstream: TerminalStudio/xterm.dart#238

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

* fix(terminal): correct the wheel report row, drop the wasted report build

Address review feedback on the wheel button fix:

- The X10/utf row was encoded as `32 + y + 1` while y is already 1-based, so
  every normal-mode report pointed one row too low and the `y > limit` guard
  disagreed with what it emitted.
- Gate the wheel path on `mouseMode.reportScroll` and the button state instead
  of building and discarding a full report string from `defaultMouseHandler`
  on every scroll tick. This also makes the hardcoded SGR 'M' provably right,
  since a wheel release now returns before the report is built.
- Derive the wire code as `id - 4` and drop `_wheelButtonId`, whose `default`
  branch was unreachable and defeated enum exhaustiveness.
- Assign `mouseHandler` after construction so the `Terminal(...)` line stays
  untouched.

Cover the utf, urxvt, null-byte overflow and click-only branches, and assert
that TerminalModel actually installs the handler.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:54:57 +08:00
rustdesk
947cb3f17b propagates the hash-handler continuation result through both connection loops, allowing incoming-only rejection to terminate the connection while preserving existing login flows. 2026-08-10 16:45:27 +08:00
RustDesk
d407db9fae fix(client): allow switch-sides back-connection in incoming-only mode (#15780)
* fix(client): allow switch-sides back-connection in incoming-only mode

"Switch sides" makes the controlled client run `--connect <peer>
--switch_uuid <uuid>`, which Client::_start rejected outright in
incoming-only custom clients, so the feature silently dropped the
session and never switched.

Exempt exactly that back-connection: a default-conn session carrying a
switch uuid may proceed. The uuid is then verified against the local
server process in handle_hash(); if it is missing there (forged or
expired), an incoming-only client now aborts with an error instead of
falling through to password login, so the outgoing-connection
restriction cannot be bypassed with a crafted --switch_uuid.

Fixes rustdesk/rustdesk#11200 (discussion)

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

* fix(client): validate switch-back grants before connecting

  - check pending peer/UUID grants before bypassing incoming-only mode
  - close rejected switch-back connections and suppress retries
  - keep grant consumption in handle_hash and test non-consuming checks

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

* fix(client): prevent switch-back UUID reuse

  - claim pending switch-back grants before connecting
  - retain claimed grants to reject duplicate requests
  - bind authorization to the peer ID and UUID
  - use a shared TTL for switch-back grants

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

* fix(client): defer switch UUID consumption until authentication

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

* fix(client): reject repeated hash login in incoming-only mode

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-08-10 16:07:12 +08:00
rustdesk
594e63805c harden login request retry 2026-08-10 16:05:13 +08:00
RustDesk
7c23fd3073 Revert "fix(linux): bound the xrandr call in the wayland primary-display look…" (#15806)
This reverts commit 2915076642.
2026-08-09 18:40:50 +08:00
Mariano Abad
2915076642 fix(linux): bound the xrandr call in the wayland primary-display lookup (#15802)
`try_xrandr_primary` runs a bare `Command::new("xrandr").output()`. Its two
siblings in the same file, `try_kscreen_primary` and the gdbus one, both go through
`run_with_timeout(.., COMMAND_TIMEOUT)`, and the comment above that helper says why:
these commands are known to hang. xrandr is the one left bare.

It matters because of where it runs. `get_primary_monitor` is called from
`get_displays` with the process-wide `DISPLAYS` guard held, and on a Wayland host
the caller can be the service, which has no DISPLAY and no session bus. An X client
that blocks there blocks every consumer of the display list behind the same lock.

No behaviour change when xrandr answers: same command, same parsing, one second of
patience.
2026-08-09 18:04:54 +08:00
lunar-me
11190fa54e docs: fix comma splice gui tutorial in README.md (#15787)
Co-authored-by: pi <pi@m2.local>
2026-08-08 09:33:58 +08:00
lunar-me
d057fe14b2 docs: fix singular contribution in docs/CONTRIBUTING.md (#15789)
Co-authored-by: pi <pi@m2.local>
2026-08-08 09:33:25 +08:00
RustDesk
4234b99029 WebClient: 3.44 webcodecs offline (#15722)
* feat(web): zero-readback WebCodecs video path

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Remove unused code

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

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

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

* remove unused code

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

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

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-07 15:20:57 +08:00
Panos
6fd96dda6e Update Greek translations for various terms (#15782) 2026-08-07 14:39:44 +08:00
Maison da Silva
429c8c6711 Translate sign-in message to Portuguese (#15770)
Translate sign-in message to Portuguese
2026-08-07 08:31:35 +08:00
RustDesk
9a81c8a138 Drm deb in release workflow (#15776)
* docs(agents): add a comment-length rule

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

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

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

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

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

Three bugs the move exposed:

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:31:09 +08:00
Mariano Abad
ddad47925c feat(linux): DRM/KMS direct capture for Wayland — no portal consent required (#15420)
* feat(drm): opt-in DRM/KMS screen capture for Linux/Wayland

adds an opt-in `drm` feature for unattended remote access on Wayland: it
captures below the compositor via libdrmtap, so there is no
xdg-desktop-portal consent dialog and it works at the login screen.

off by default. when the feature is off the build is byte-identical.
everything is gated behind feature = "drm" or lives only in the separate
rustdesk-unattended-wayland deb, whose package name is the informed consent.

architecture (agreed with the maintainer): the capture runs inside the root
--service, which already holds the privilege it needs, and streams frames to
the user --server over a service-scoped _drm ipc channel. libdrmtap is loaded
with dlopen at runtime (no link-time dependency, so the base build is
unchanged and it still runs on ubuntu 18), and the .so is built in ci from the
rustdesk-org/libdrmtap fork and shipped only in the drm deb. no setcap helper.

- service: DrmReader reads scanout directly via the dlopen loader; an
  IpcDrmCapturer serves _drm consumers with a per-connection capture worker;
  durable availability cache + pre-warm to avoid enumerate/re-probe restarts
- capture: multi-display (targets the selected crtc), hardware cursor over
  _drm, transient-errno retry with a bounded stall, rejects non-32bpp scanouts
  before the frame copy
- robustness: only active, crtc-bound outputs are offered (an unbound
  crtc_id=0 connector is filtered and a client-selected 0 is refused, both
  fall back to pipewire); a per-display rapid-rebuild guard demotes a flapping
  display to pipewire; per-display (not global) zero-frame failure tracking
- root-service hardening: bounded frame allocation and a concurrent-connection
  cap so a malformed scanout or a buggy consumer cannot OOM or thread-exhaust
  the service; a negative availability verdict expires so displays that appear
  after startup recover without a --server restart; exactly-one .so selection
  in the packaging so a stale object is never silently shipped
- build: libdrmtap.so cloned at build time from rustdesk-org/libdrmtap main
  and bundled only for the --drm deb; ci builds a separate
  rustdesk-unattended-wayland deb (incl. an ubuntu 18.04 container)
- DRM_CAPTURE_SECURITY.md: threat model and hardening notes

* feat(drm): phase-2 split, pass the dma-buf fd instead of the converted frame

move the egl detile and rgba pack out of the root --service and into the
unprivileged --server. the root now calls only drmtap_open + drmtap_grab_desc
and exports a raw dma-buf fd; the fd rides the _drm channel over SCM_RIGHTS with
a small descriptor (geometry, per-plane offsets/pitches, modifier, hdr) instead
of the full rgba frame, dropping the per-frame copy. the --server imports the fd
with drmtap_open_render + drmtap_convert_dmabuf, keyed by the import-once egl
cache, and the render context is created and dropped on the recv thread.

the _drm transport moves off Framed<BytesCodec> (which cannot carry a fd) to a
bespoke sendmsg/recvmsg framing (DrmConn) that attaches one SCM_RIGHTS cmsg only
when a fd is present and rejects a truncated ancillary message. the split
symbols are bound optionally so an older libdrmtap still loads the cpu path, and
the whole thing degrades to the cpu BGRA path or PipeWire when no render node is
available. pins libdrmtap-sys to =0.4.13 with the Cargo.lock checksum. folds in
the DP-MST, ldconfig-restart and per-display PipeWire-fallback review fixes and a
udev hotplug refresh.

* drm: address the phase-2 split review

1- do not depend on the libdrmtap-sys crate for the pin: its build.rs statically
compiles the whole libdrmtap C tree and a CAP_SYS_ADMIN helper and links
-ldrm/-lseccomp/-lcap, which defeats the runtime-dlopen model. keep drm a pure
dlopen backend and pin the .so by the build.py DRMTAP_REF release tag, guarded by
a strict vX.Y.Z regex. drops the now-moot Cargo.lock freshness CI checks.
2- render-node-less consumers no longer lose the stream: the --server signals
need_cpu on DrmStart when it cannot open a convert context, and the --service
streams the CPU-converted frame path for that connection instead of a dma-buf fd
the consumer cannot detile (which used to fall through to a PipeWire path nobody
can approve on an unattended seat).
3- mark PipeWire initialized only after every per-display capturer is created, so
a partial failure retries instead of the flag falsely reporting a complete init.
4- reject a degenerate (zero width/height) or short CPU frame before it reaches
PixelBuffer::new (which derives stride as data.len()/height, dividing by zero).
5- keep the export-ledger epoch at DRM_DISPLAY_GENERATION so a hotplug invalidates
cached buffers (elision stays off until the recycled-fb_id inode case is handled).
6- validate the udev uevent source (kernel nl_pid, multicast) with recvmsg so a
local process cannot unicast a spoofed drm-change event to the root listener.

* drm: second review pass on the phase-2 split

1- make PipeWire init atomic: build every per-display capturer into owned staging
first and publish them to CAP_DISPLAY_INFO only after all succeed, so a mid-loop
Capturer::new failure neither leaves partial entries (which the next check_init
would treat as already-initialized) nor leaks the raw pointers already created.
2- pin the immutable libdrmtap commit, not just the tag: git clone --branch
follows a mutable tag, so verify the cloned HEAD equals DRMTAP_SHA in both the CI
workflow and build.py, failing on a moved/compromised tag.
3- drop the stale comment claiming a libdrmtap-sys crate pin (the drm backend has
no such dependency).

* drm: harden the libdrmtap source pin

1- verify the commit-SHA pin on a reused checkout too, not only on a fresh clone:
a stale or mismatched third_party/libdrmtap (e.g. from a failed clone) is now
removed and the build fails instead of silently reusing unpinned source.
2- default DRMTAP_REPO to the fork that actually publishes the pinned tag, so a
clean git clone --branch v0.4.13 resolves (and to the expected commit) instead of
failing on a repo that does not carry the tag.

* ci: make the pinned libdrmtap commit SHA literal

do not let an inherited DRMTAP_SHA override the verified commit in CI, so the
tag/commit pair is immutable there. build.py keeps the env override for local
forks.

* drm: only SHA-verify a git libdrmtap checkout, not a local source tree

gate the commit-SHA pin check on third_party/libdrmtap being a git checkout, so a
clone (fresh, reused, or a stale/failed one) is still verified, but a non-git
source tree a developer placed there on purpose to build unreleased local
libdrmtap is used as-is (it has no tag to verify).

* build: request the libdrmtap shared_library target explicitly

since libdrmtap 0.4.11 the project builds both a shared object and a static
archive, so 'meson compile drmtap' is ambiguous. ask for drmtap:shared_library
(rustdesk dlopens the .so and never links the archive).

* drm: do not reject a non-BGRA scanout on the export side

grab_desc exports the raw scanout dma-buf; the unprivileged converter handles
every format libdrmtap supports (10-bit XR30/AR30 with tone mapping, HDR, CCS)
down to RGBA. The fourcc gate copied from the CPU-mapped grab() wrongly closed
the _drm stream for a 10-bit XR30 primary (0x30335258) that convert_dmabuf
converts fine -- observed live on an i915 seat scanning out XRGB2101010. Keep
the gate only on grab(), whose frame.format is already the converted BGRA.

* drm: do not restart-loop a demoted display PipeWire cannot serve

DRM and PipeWire do not share a display-index space: DRM enumerates one entry
per connector while the portal often exposes a single whole-desktop stream at
index 0. When a per-display DRM capture was demoted to PipeWire for a non-primary
DRM index, cap_map.get(&display_idx) was None and the bail Err made
ServiceTmpl::run retry get_capturer every 1s forever (a multi-monitor restart
loop, latent until a display demotes). Degrade to the whole-desktop stream
(index 0) PipeWire does provide instead of spinning. Healthy DRM displays return
before this and are unaffected.

* ci: build the libdrmtap shared_library target explicitly

the CI .so-prebuild step used the same bare 'drmtap' meson target that is
ambiguous since libdrmtap became both_libraries (0.4.11); ask for
drmtap:shared_library, matching build.py.

* drm: stop altering the stock (drm-off) Wayland path (review 3.2, 4.6)

3.2: get_capturer_for_display no longer falls back to cap_map[0] for a missing
index. CapturerPtr is a bare *mut Capturer cloned by raw-pointer copy, so aliasing
one entry to two display_idx values let two video-service threads call frame() on
the same Recorder unsynchronised (data race / UB), reachable in a plain build via
CaptureDisplays{set:[0,3]}. Restore the exact-index lookup + bail; a demoted DRM
index is dropped from the advertised list at the source instead.
4.6: revert check_init to upstream (flag set before the per-display loop, direct
insert). The staged-all-or-nothing variant turned a partial per-display failure
into a permanent 1Hz retry loop and was not drm-gated. Both restore the drm-off
build to byte-identical with upstream.

* drm: address review findings 3.1, 4.2, 4.3, 4.4, 4.7 + minors

3.1: snapshot the stock flutter bundle before the CI drm relink and restore it
before makepkg, so the official Arch package ships the stock cdylib, not the
drm-enabled one. 4.2: wrap the drm block in a failure-tolerant subshell so a
drm-only failure no longer aborts the stock deb/rpm/arch publish. 4.3: narrow the
publish glob to rustdesk-[0-9]*.deb so the consent-bypass unattended-wayland deb
stays an artifact, not on the public release. 4.4: rewrite the three stale
DRM_CAPTURE_SECURITY.md statements to the split (default path passes a read-only
scanout dma-buf fd over SCM_RIGHTS with an import-once cache; export validation is
metadata-only; BGRA-over-the-wire is the fallback) and document that grab_desc's
fd is O_RDONLY (DRM_RDWR dropped upstream, dup preserves it). 4.7: only
short-circuit to the DRM cursor when it is authoritative (visible, or hidden in a
pure-DRM session); fall through to the normal cursor path in a mixed
DRM+PipeWire session. minors: thread the deb variant by feature not glob; TODO
for the ld.so.conf.d system path; drop a stray blank line. All gated or
whitespace so the drm-off build stays byte-identical.

* drm: re-authorize the _drm stream per frame and auth the producer (review 3.3, 4.1)

3.3: DRM/KMS capture is not session-scoped -- the worker grabs a CRTC's physical
scanout regardless of which session owns the display -- but the peer was
authorized only once at accept. Capture the peer uid and re-check it at the top of
the forward loop: root is always allowed, any other peer must still be the
active-session uid, fail closed otherwise. A session change now tears the stream
down within one frame (~33ms) instead of leaking the incoming user's screen to the
outgoing user's --server.
4.1: connect_drm accepted any producer. Reject a non-root peer (peer_uid != 0) so a
process that won the socket-path race cannot feed the consumer a display list,
frames and dma-buf fds while the DRM path suppresses the portal consent prompt.

* drm: validate cursor body length and coalesce _drm frames to latest-wins (review 4.1, 4.8)

4.1: the DrmCursor consumer handed the wire body straight to the client, which
renders width*height*4 RGBA bytes. Reject a body shorter than that so a truncated
cursor cannot make the client read past the buffer. The hidden-cursor sentinel is
0x0 with an empty body, for which the bound is 0 and the check is a no-op.
4.8: the _drm socket is a FIFO, so a consumer that drains slower than we produce
(a 4K convert on a modest GPU) fell seconds behind stale frames. Drain the producer
channel without blocking each tick and forward only the newest frame; replaced
frames drop in place, closing the zero-copy OwnedFd and freeing the CPU-path pixel
buffer. Cursor updates stay in order and are never coalesced away.

* drm: keep the demoted-display list consistent instead of stretching PipeWire (review 4.5)

A DRM display demoted to PipeWire has no geometry-consistent per-connector stream
on a multi-monitor host -- the portal exposes a single whole-desktop stream. The
fallthrough served that whole-desktop frame while the list still advertised the
demoted connector geometry, so the client stretched the frame and offset all input
by the connector origin (the primary-index-0 demotion reaches this even after the
get_capturer_for_display exact-index fix).

Dropping the display from the list is not an option: its position IS the capturer
index, so a drop would shift every later display and desync get_capturer_info. So
instead: get_display_infos advertises a multi-monitor demoted display OFFLINE at its
stable index, and get_capturer_for_display serves the PipeWire fallback only when
its rect matches the advertised geometry, else bails. A single-display host still
falls through (whole-desktop == that display). All new logic is drm-gated.

* drm: bound the _drm body read, stream-scope cursor teardown, refresh a stale verdict, drop dead clear (review 5)

- recv_msg_timeout2 only gated the wait for the first byte, so a peer that sent one
  byte then stalled pinned the task forever. The same budget now also bounds the body
  read; a body that overruns is a hard error that tears the stream down (recv_msg
  bodies are small JSON, so a healthy peer never trips it).
- The cursor cache is keyed by display index, which a rebuilt stream reuses, so a
  predecessor exiting after its replacement published a fresh cursor erased it. Stamp
  each entry with a monotonic per-stream epoch and compare-and-remove on teardown.
- ProbeState::Available had no TTL, so an idle hotplug left a phantom display in
  enumeration. Give it a timestamp and refresh the list off the hot path once it ages
  past POSITIVE_TTL. The verdict stays true across the refresh (never bounces a live
  session to the portal) and the probe runs on a background thread (never blocks the
  async enumeration).
- Remove the dead clear(): it is unreferenced, and wiring it into teardown would force
  the blocking re-probe on the next enumeration that swap_available_displays exists to
  avoid.

* drm: unit-test the bespoke _drm SCM_RIGHTS framing (review 6)

The _drm wire format is hand-rolled (length prefix plus an fd bound to the frame
first byte) because Framed/BytesCodec cannot carry ancillary data, so it had zero
tests. Add pure-userspace coverage over a socketpair:
- a control message round-trips with and without an attached fd, and the received fd
  refers to the same open file (a byte written into the source is read back through it)
- a raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte
- a forged length prefix past the JSON cap is rejected at the prefix
- surplus fds packed into one cmsg keep only the first and close the rest
- a control message truncated past DRM_CMSG_CAP is rejected (MSG_CTRUNC), not consumed
- peer_uid_from_fd reads the socket peer credential the producer-auth path relies on

* drm: address the self-review findings on the review rework

Five defects an adversarial pass found in the previous commits:
- refresh_available_async set the single-flight probe guard, then relied on the
  detached thread to clear it; if thread creation failed (EAGAIN) or the closure
  unwound, the guard leaked true and froze every future probe. Release it via RAII
  inside the closure and on a Builder::spawn error.
- The _drm per-frame re-auth called the cached active_uid(), which on a cache miss
  (exactly during a session switch) falls back to a blocking loginctl seat0 lookup --
  on the single-threaded _drm runtime, once per frame, a subprocess storm. Use a new
  cache-only accessor that never blocks and fails closed on a miss, and correct the
  comment: the stop is bounded by the active-uid cache cadence, not one frame.
- set_drm_cursor inserted unconditionally, so a still-draining predecessor stream
  could overwrite (then delete on teardown) the cursor a replacement stream published
  for the same index. Make it a compare-and-set that ignores an older epoch.
- recv_msg_timeout2 treated a spurious readable() wakeup with nothing consumed as a
  mid-frame stall and tore the stream down. Track whether any byte was consumed
  (drm_read_full sets it) and map a zero-progress deadline back to None (re-poll),
  reserving the hard error for a genuine partial-frame stall.

* drm: release the probe single-flight guard via RAII on the cold path too

The cold availability probe in is_available acquired DRM_PROBE_IN_FLIGHT and released
it with a plain store(false) after a synchronous body; a panic there (e.g. a poisoned
DRM_STATE lock) would leak the guard true and freeze both future probes and the
refresh path hardened in the previous commit, since they share the guard. Hoist the
release into a shared ProbeInFlightGuard used by both the cold probe and the refresh
closure, so any exit -- normal, early, or unwinding -- clears it.

* drm: source libdrmtap from rustdesk-org, pinned by sha (review 3.4)

The dlopened .so is loaded into the CAP_SYS_ADMIN root service, so it should come
from the maintainer-owned repo, not a personal fork. rustdesk-org/libdrmtap main is
already synced to the exact commit we pin (c9cf0938 = v0.4.13) but carries no release
tag, so point both build.py and the CI job at rustdesk-org and track main with the
immutable commit pinned via DRMTAP_SHA. The post-clone sha check makes this
fail-closed: main moving off the pinned commit fails the build instead of silently
swapping the .so. The CI ref guard now accepts a vX.Y.Z tag or main (a loose branch is
still rejected). Switch DRMTAP_REF to a tag if rustdesk-org later publishes one.

* drm: dlopen libdrmtap by absolute path + unit-test the _drm admission and re-auth (review 5e, 6a)

5e: the deb dropped /usr/lib/rustdesk into /etc/ld.so.conf.d so the private libdrmtap
could be found by soname -- a system-wide search-path entry that lets it shadow a
system library for every binary on the host, which Debian Policy 10.2 forbids. Resolve
it by absolute path (/usr/lib/rustdesk/libdrmtap.so.0) at the dlopen site instead, with
the bare sonames kept only as a dev fallback, and drop the ld.so.conf.d file and the
ldconfig/try-restart postinst entirely (the .so is present at its absolute path right
after unpack, so the pre-warm resolves with no linker-cache step). The dlopen site is
this PR's own code, so this is in scope, not a follow-up.

6a: extract the _drm admission bound and the per-frame re-auth decision into pure
helpers (drm_conn_admitted, drm_peer_authorized) and unit-test them: admission admits
strictly below MAX_DRM_CONNS and rejects at/above it; re-auth passes root always,
passes a non-root peer only while it equals the active-session uid, and fails closed on
a switched-away, unknown-session, or unknown-peer case. (The /proc/exe-mismatch
rejection is exercised by the accept-time authorize call; unit-testing it in isolation
would need a second process with a different exe, so it stays an integration concern.)

* ci: run the _drm unit tests on every PR (review 6)

The _drm unit tests are behind the opt-in drm feature, which the default workspace
test job does not build, so they would sit in the tree unrun -- no better than no
tests. Add a Linux step to the per-PR ci.yml that runs them with the feature on,
alongside the existing ipc/auth tests. drm is a pure runtime-dlopen backend with no
link-time deps (no libdrm/EGL/gbm) and the tests are pure userspace (socketpair
framing, SCM_RIGHTS, the peer-auth/admission decisions), so this needs no GPU and no
extra system packages. The main build/test stays on default features, so the shipped
drm-off config remains the primary verified one.

* drm: bump the pinned libdrmtap to v0.4.14

Point the DRM capture build at the libdrmtap v0.4.14 release commit
(816766dedaba3140c613712ce97aa2614e8899e7) instead of v0.4.13, in build.py and
the flutter-build workflow, and correct the scrap Cargo.toml note to describe
the actual DRMTAP_SHA anchor. 0.4.14 keeps the same public API, so the dlopen
consumer needs no change.

* drm: address the consumer review (login-screen uid, frame flow control, hotplug)

- Start the login-screen --server as the active seat0 greeter account instead
  of root, so the DRM capture GPU/EGL convert never loads the vendor GPU
  userspace in a privileged process. A genuine root graphical session has no
  lower uid to drop to and stays root, and if the greeter spawn fails we fall
  back to a root --server so the login screen stays remotable. Gated on the drm
  feature so the non-drm build is unchanged.
- Bound the number of frames in flight on the `_drm` channel: the consumer acks
  each frame it finishes converting and the producer only sends while it holds
  credit, waiting on the socket otherwise. Without this the producer kept
  writing descriptors into the socket faster than a slow convert drained them
  and the consumer worked through an ever-growing backlog of stale frames. A
  zero-byte read or write on the ack path is treated as a closed peer rather
  than as success.
- Forward a display list that became empty (last monitor unplugged) instead of
  dropping it, so the availability cache leaves Available rather than keep
  advertising removed displays.
- On a topology change, invalidate the Wayland geometry cache and reapply the
  uinput mouse range for the new layout. The refresh runs off the frame-receive
  loop and is coalesced across the per-display receivers, so a multi-monitor
  hotplug runs one worker and the final layout wins.
- Clear the prefer-CPU-convert hints on a topology change: display indices can
  be renumbered, so a hint learned for an old index no longer refers to the same
  physical display. Re-learned on the next convert failure.
- Report a non-DRM-backed display when the DRM list is shorter than the sync
  list or any entry is offline, covering the present-but-demoted case.

* drm: log why the uinput refresh worker could not start

The worker released its coalescing slot and returned silently when the runtime
failed to build, leaving the uinput range stale for the new layout with nothing
in the log to explain it.

* drm: gate only frames on send credit, never cursor or topology updates

The credit check sat at the top of the producer loop and continued on exhaustion,
so while a slow convert withheld its ack the loop never reached the code that
forwards cursor updates and pushes a changed display list: the remote cursor
froze and a hotplug went unreported until credit returned. The comment claimed
those were not credit-gated; structurally they were.

The loop now always receives and processes producer messages. Only the frame send
is gated: when credit is exhausted the newest frame is held back (latest-wins,
matching the existing coalescing) and flushed as soon as an ack lands, while
cursors and the topology push go out unimpeded. While a frame is held the loop
also waits on the socket, so an ack wakes it promptly rather than only when the
next frame arrives; both select arms are cancel-safe.

* drm: fix three defects in the frame credit gate

Follow-up to the previous commit, from an adversarial review of it.

- The ack wake-up skipped the coalescing drain. When the socket arm of the
  select won, there was no message to seed the drain loop with, so the channel
  was never polled that iteration: a held frame could be sent while a strictly
  newer one already sat queued, and a queued cursor waited for the next producer
  message. Seed the loop from the channel when we woke on an ack instead.
- The loop could wait while holding a frame it was allowed to send. Credit
  replenished by the top-of-loop drain was not consulted before entering the
  select, so the frame waited for the worker's next message; if capture then
  returned WouldBlock it sat there until the stall teardown. Take whatever is
  queued without blocking in that case and fall through to the send.
- The capture worker no longer had any backpressure. Draining the channel every
  iteration (needed so cursors keep flowing) means a full channel no longer
  parks it, so a consumer converting at a fraction of the capture rate made the
  privileged service keep grabbing frames that were then discarded -- a packed
  copy per frame on the CPU path, a PRIME export on the dma-buf path. The worker
  now skips the grab while the task is holding an undeliverable frame, and keeps
  polling the cursor so the remote pointer stays live. The gate is deliberately
  conditioned on holding a frame, not merely on having no credit: with nothing
  held the task blocks in recv() and cannot observe an ack, so gating there
  would stop the worker feeding it at all.

The comment claiming the bounded channel backpressures the worker is corrected.

* drm: gate capture on credit alone, and bound the no-credit wait

Follow-up to the previous commit, from an adversarial review that modelled the
loop with a real runtime, socket pair and worker thread.

Gating the worker only while a frame was already held was wrong: those grabs are
not wasted work, they keep the held frame fresh, because the coalescing below
lets each newer frame supersede it. Pinning the worker at that moment therefore
froze whatever frame happened to be in hand when credit ran out and shipped it
stale once the ack landed -- measured at ~91ms average staleness against ~2ms
with no gate at all. Gating on lack of credit alone, and waiting on the socket
whenever credit is out rather than only while holding a frame, keeps the CPU
saving (the worker still stops grabbing) with no staleness: the ack resumes the
worker and what goes out is a fresh grab. Modelled at 0ms staleness and the same
delivered-frame count, with 31 grabs versus 588 ungated. It is deadlock-free
because the socket is watched in exactly the states where the gate is set.

The no-credit wait is now bounded (5s). While gated the worker does not grab, so
it cannot advance its own MAX_STALLED watchdog; a consumer that stopped acking
without closing the socket could otherwise hold this connection, its worker
thread and the privileged DRM context open indefinitely.

* drm: measure the no-credit deadline from the last ack, not the last wake-up

The bound added in the previous commit was a timeout on the wait itself, so any
wake renewed it -- and cursor messages keep arriving while frames are gated, so
a consumer that had stopped acking but still moved its pointer would renew the
deadline forever and never be torn down. Track when we last held credit instead
and enforce the deadline against that, keeping the wait capped only so we still
wake to re-evaluate it when nothing arrives at all.

* drm: drop to Unavailable when the background refresh finds no displays

The review asked for two things when the last CRTC disappears: push the empty
topology to consumers, and stop advertising the removed displays. Only the first
was done. The positive-TTL refresh still discarded an empty probe result and kept
the previous list, so on an idle host -- where there is no live stream to carry
the hotplug push -- enumeration kept reporting displays that were gone, exactly
as described. It now transitions to Unavailable on an empty result, matching the
hotplug path, while a failed probe (transient open/EACCES, not evidence the
displays are gone) keeps the verdict and only restamps it.

* drm: do not let a stale availability probe overwrite a newer verdict

query_displays() in the background refresh runs unlocked because it is slow, so
a hotplug push can publish a newer verdict while it is in flight; the refresh
then overwrote it with its own older result. Harmless while it only replaced the
list, but the previous commit made an empty result drop to Unavailable, so a
probe that started while the monitors were gone could disable DRM on a host
whose monitor had since come back.

The refresh now samples the stamp of the verdict it is refreshing and publishes
only if that stamp is still current. Every publish stamps a fresh Instant, so an
unchanged stamp means nothing republished in between -- equivalent to threading a
revision counter through every publish site, without having to keep all of them
in sync.

* drm: track availability publishes with a generation, and hold the probe guard across the whole path

Two defects in the previous commit's staleness check.

The single-flight guard was still created inside the spawned closure, but that
commit added a DRM_STATE lock before the spawn. A poisoned lock there would
unwind past the flag with nothing to clear it, leaving DRM_PROBE_IN_FLIGHT set
and freezing every future probe. The guard is now taken immediately after the
flag is acquired and moved into the closure, so it covers the lock, the probe,
and a failed spawn alike. The explicit release on spawn failure is gone with it:
it was not merely redundant but wrong, since by then another refresh may have
acquired the flag and clearing it would let two probes run at once.

The staleness check itself compared Instant stamps, which made correctness
depend on an implicit invariant -- that every publish restamps -- spread across
ten call sites; a future publish that reused a stamp would defeat it silently.
DRM_STATE now carries an explicit generation, bumped by publish_probe_state,
which every write to the state goes through. Instants are left to serve only the
TTL checks. The failed-probe branch deliberately restamps without bumping: it
touches the TTL, not the verdict, so a concurrent probe loses nothing by
publishing over it.

* drm: convert each display on the GPU that exports it

The unprivileged converter opened its render context with
drmtap_open_render(NULL), letting libdrmtap auto-select. On a multi-GPU host
that can land on a different GPU than the one driving the display, and importing
a scanout across vendors can fail permanently on an incompatible tiling
modifier.

The service already knows the exporting device, so it now names its render node
(drmtap_render_node, libdrmtap 0.4.15) in each DrmDisplayInfo, and the consumer
opens the converter on that node. The field is serde(default) and empty means
auto-select, so a service and a server from mismatched builds still interoperate
and a pre-0.4.15 .so degrades to exactly the previous behaviour. The path is
realpath-gated to /dev/dri before it is opened, the same gate the capture device
gets, since it arrives over IPC. When the named node cannot be opened the
converter returns None and the existing need_cpu fallback runs the convert on the
exporting GPU service-side, which is the most correct place for it anyway.

Added a wire-compat test that a pre-render_node DrmDisplayInfo payload still
decodes (empty node) and a current one round-trips the node.

* drm: advertise the displays of every GPU, not just the first card

A drmtap context is bound to a single DRM device, so the service enumerated one
auto-detected card and advertised only its monitors. On a multi-GPU host every
display driven by another card was invisible to the client, and its card-local
CRTC id could not have been opened through the wrong device anyway.

The service now enumerates every card (drmtap_list_devices, libdrmtap 0.4.15),
opens one reader per device, and merges their displays into the one list, each
tagged with its own card node and render node. DrmStart resolves the chosen
index to that display's device + CRTC and the worker reopens the right card;
the converter already binds the display's render node. Both new fields are
serde(default) and empty means the single auto-detected device, so a pre-0.4.15
.so and a mismatched-build peer keep the previous behaviour exactly.

Enumeration replaces the single-reader open in the pre-warm, the udev hotplug
refresh, and the per-connection handshake, so a hotplug on any card is picked up
and an all-monitors-off state now correctly publishes an empty list. The
per-connection cache refresh re-enumerates all cards rather than only the
connection's device, so serving one display never drops the others from the
next handshake.

Verified on a Jetson Orin (its two DRM devices, only card2 driving a display):
list_devices reports card2/renderD129 with one display, enumeration produces
exactly that display tagged to card2, and card1 (no active CRTC) is skipped -
no phantom, no regression on the single-display case.

* drm: bump the pinned libdrmtap to v0.4.15

* drm: do not guess the exporting GPU when the host has several render nodes

The converter binds the render node the service names for a display, and falls
back to auto-selection when that name is empty. An empty name is what an older
libdrmtap produces: the service resolves it with drmtap_render_node, which only
exists since 0.4.15, and rustdesk dlopens libdrmtap.so.0 by soname, so the
runtime library can be older than the one the build was pinned to.

Auto-selecting is not safe there. On a single-SoC multi-device host the wrong
choice does not fail: a Jetson Orin exports the scanout from nvidia-drm while
the first render node belongs to tegra, and importing the scanout on the tegra
node SUCCEEDS and yields corrupted pixels. There is no convert error, so the
prefer-cpu bit never learns anything and the stream simply looks broken with a
clean log.

Request the CPU-converted path instead whenever the exporter is unnamed and the
host exposes more than one render node: the service converts on the device it
already has open, which is correct by construction. Hosts with a single render
node have nothing to pick wrong and keep the dma-buf path untouched.

Verified on a Jetson Orin Nano, the two-device host: with a libdrmtap that
lacks drmtap_render_node the capture used to come through visibly corrupted,
and now falls back to the cpu path and renders correctly. With 0.4.15 the
service names renderD129 and the dma-buf path is used as before.

* drm: name the libdrmtap that was really loaded, and say so when it is stale

Two hours went into a corrupted capture whose only symptom was a clean log
saying "libdrmtap loaded: /usr/lib/rustdesk/libdrmtap.so.0 (v0.4.15)". The
library behind that soname symlink was a pre-release 0.4.15 that reported the
version but did not export drmtap_render_node, so the service silently stopped
naming the exporting GPU. The log named the symlink it asked for, which is not
evidence of anything, and the version it printed came from the library itself,
which was the part that lied.

Log the file the absolute candidate actually resolves to, and warn when a
library reports 0.4.15 or newer while missing drmtap_render_node or
drmtap_list_devices, naming that file: a version that claims features the
symbols do not back means a stale or pre-release build, and the effect is
invisible otherwise. Only the absolute candidate is resolved, because dlopen
does not search the process CWD for a bare soname while canonicalize would.

Also correct two places that no longer matched the code: the security document
still described an /etc/ld.so.conf.d drop-in and an ldconfig trigger that
build.py deliberately does not ship (the .so is dlopened by absolute path and
the package makes the soname symlink itself), and the comment above the render
node lookup still said an unnamed exporter always falls back to auto-selection.

* drm: tighten the render-node count and the loader diagnostics

Four corrections from a review pass over the previous two commits.

Count only a render node whose name is renderD followed by a numeric minor.
The prefix test also matched something like renderD.backup, which would have
inflated the count and pushed a genuinely single-GPU host onto the CPU path.

Log the load only after every required symbol resolved. load() still returns
None when one is missing, so announcing success first could print "libdrmtap
loaded" and then "libdrmtap not available" for the same library.

Name only the capability each absent symbol costs: a library missing just
drmtap_render_node loses exporting-GPU selection, one missing just
drmtap_list_devices loses multi-GPU enumeration, and the previous wording
claimed both were gone in either case.

Fix the security document's audit step. The dlopen names the symlink by
absolute path and the package registers no linker directory, so a leftover
object beside it is not loaded on its own; what matters is where the symlink
points, and a leftover only matters as what a stray ldconfig would repoint it
to. Ask the auditor to read the symlink target instead.

* docs: list every case that selects the CPU-converted frame path

The security document described the CPU fallback without saying when it is
taken, and the multi-GPU safety fallback added in this branch was not mentioned
at all. Enumerate the four cases, including the one where the service could not
name the exporting GPU on a host with several render nodes, and note that a
single-render-node host keeps the DMA-BUF path.

* drm: fetch libdrmtap by commit sha instead of cloning a branch

`git clone --depth 1 --branch main` fetches only the tip of that branch, so the
moment upstream pushes to libdrmtap `main` the pinned commit is no longer present
in the shallow clone at all: the build fails on an unreachable object rather than
on a mismatched pin, and it fails for a reason that has nothing to do with the
checkout being wrong. In the release workflow the whole block is wrapped so the
job stays green, which means the drm deb would simply stop being produced without
anyone noticing.

Fetch the sha directly instead. No branch or tag name takes part in the build now,
so it survives every upstream push and cannot be affected by a ref being moved or
repointed. DRMTAP_REF is gone, along with the regex that validated it.

The post-fetch sha check stays, with a narrower job: a fetch by sha cannot resolve
to anything else, so it now guards a reused checkout left at a different pin, which
is exactly what a version bump leaves behind. It still removes that tree so the
next run re-fetches cleanly.

build.py is now the single source of truth for the pin.

* drm: move the drm CI out of the stock workflow, and stop touching scrap/Cargo.toml

The instruction was that nothing outside the feature should change while the
feature is off, and the runtime code honors that, but the build plumbing did not.
Start undoing that.

ci.yml goes back to upstream byte for byte. The drm test step it carried now lives
in a new workflow that only fires when a drm path changes, so a PR that does not
touch this backend pays nothing for it.

That new workflow also runs the whole rustdesk-crate test set with the feature on
rather than filtering by the `_drm` test names, because the name filter skipped
the sibling assertion that bounds `size_of::<Data>()`, which the new DmabufDesc
variant grows.

It gains a second job that fetches libdrmtap at the pinned commit, builds the .so
and then asserts the contract the runtime depends on: every symbol the loader
resolves, derived from the loader source so the two cannot drift, plus evidence
that the EGL detile path is really compiled in. libdrmtap degrades to a CPU-only
stub when the egl/glesv2 pkg-config files are absent on a build host, and nothing
downstream noticed. Note the check looks for the dlopen target name and the import
call, not for DT_NEEDED: EGL is loaded lazily on purpose so the privileged process
never links the vendor GL stack, so an ELF-level check reports a false negative on
a correct library.

libs/scrap/Cargo.toml keeps only the added feature: the unrelated blank line before
[dependencies.hwcodec] is restored, and the comment no longer describes DRMTAP_REF,
which no longer exists. The feature is now drm = ["wayland"] because all three drm
modules live inside the wayland arm of common/mod.rs, so scrap/drm alone compiled
nothing; it worked only because the root crate always enables scrap/wayland.

* drm: build the unattended-wayland deb in its own workflow, not in the release job

flutter-build.yml goes back to upstream byte for byte. Three separate changes to
the stock release path disappear with it: the drm variant built inside the release
container, the snapshot and restore of the stock flutter bundle that existed only
to keep the drm relink out of the archlinux package, and the narrowing of the
publish glob to keep the consent-free deb off the public release.

The deb now builds in the drm workflow instead, which also removes the failure
mode the old placement forced: the whole block had to run in a subshell ending in
`|| echo WARN` so a drm-only breakage could not abort the stock publish steps,
which meant every failure in it, from the fetch to meson to packaging, kept the
job green and silently stopped producing the deb. A separate job can just fail.

The bridge generator is a reusable workflow, so this calls the stock one rather
than duplicating the codegen.

The deb is asserted rather than trusted: build.py can exit 0 without producing a
package, so the job checks the file exists and that it carries both the real
libdrmtap object and its soname symlink. It stays an artifact and never a release
deliverable, and it is built on the runner rather than in the old container the
stock debs use, so its glibc floor is higher than a released package.

* drm: stop refactoring the shared packaging path in build.py

generate_control_file goes back to upstream byte for byte: no extra parameters, no
conditional inside it. The variant instead rewrites the control file that function
just produced, so everything specific to the consent-free package lives in added
code rather than in the shared one. That rewrite fails loudly if either anchor line
stops matching, so a future upstream change to the control layout cannot quietly
yield a variant deb wearing the stock package name.

finalize_deb is gone. It had pulled the tail of both deb builders into one shared
helper, which is a refactor of a path the feature has no business touching. Both
builders now carry their upstream tail verbatim, with the drm work added as three
guarded blocks: stage the library, retarget the control, rename the output. With
the feature off, every line is upstream's.

Verified rather than argued, by building both packages with this script:
the drm deb is Package: rustdesk-unattended-wayland, carries Conflicts, Replaces
and Provides on rustdesk, has libdrm2, libegl1 and libgles2 appended to Depends,
and ships libdrmtap.so.0.4.15 plus its soname symlink. The stock deb is
Package: rustdesk, carries none of those three fields, and contains no libdrmtap
file at all.

* drm: key per-display state by connector identity, and end a stream whose index moved

The service binds a stream to (device, crtc_id), which survives a topology change.
Everything on the consumer side addressed it by list index, which does not:
drm_enumerate_all_displays concatenates per-card lists, so plugging or unplugging a
monitor renumbers every display after it. Two consequences, one live and one
remembered.

Live: a running stream kept sending monitor A while the advertised list, and so the
client layout and the injected-input rect, had come to mean monitor B. It only
resolved if the stream happened to fail on its own. The stream now records what it
was bound to and ends itself when its index stops meaning that, which routes the
change through the rebuild the video service already does.

Remembered: the zero-frame failure counts and the prefer-cpu verdicts were keyed by
index too, so after a renumbering one monitor could inherit another's demotion or be
forced onto the CPU convert path for a mismatch that was never its own. Both are now
keyed by device plus connector name. The reasoning was already written down for one
of these, in the comment above the prefer-cpu clear, and applied only there.

That bulk clear is gone with it. It existed to limit the damage of index aliasing;
with identity keys it would instead throw away a correct verdict, which costs a real
convert failure to relearn, on every unrelated hotplug.

Also fixes the drm workflow to skip the two tests the stock CI already skips. Both
need a display server and fail on any headless runner, so the job would have gone
red for a reason that has nothing to do with this feature. Verified by running the
exact command: 88 tests, including the size_of::<Data>() assertion that the old
name filter was hiding.

* drm: end the session when the captured display changes geometry mid-stream

A resolution DECREASE wedged the stream. The encoder is sized once, from
CapturerInfo at capturer build time; check_display_changed returns None on Wayland,
so the periodic display-changed broadcast never fires there; and convert_to_yuv only
bails when the source is LARGER than the destination. A smaller frame therefore
passed all three and was encoded into the previous canvas, leaving stale content
along the right and bottom edges for the rest of the connection. An increase
recovered only by accident, because convert then refused and the service rebuilt.

This is ours to contain rather than merely inherited: the DrmDisplaysChanged
handler re-broadcasts the new geometry through SYNC_DISPLAYS, so the client layout
and the pixels it receives actively disagree, where before there was no topology
signal at all.

The capturer now records the geometry its session was built with and returns a hard
error from frame() when a dequeued frame differs, which routes a shrink through the
same rebuild an enlargement already takes. got_frame is set first so a session that
did deliver frames is not counted as one of the zero-frame sessions that demote a
display to PipeWire.

The general fix belongs to the Wayland path rather than to this backend, and is
filed separately as #15695.

Four tests cover it, the first in this file: the matching size is delivered, a
smaller and a larger frame both end the session, and an unknown session size stays
out of the way instead of rejecting everything.

* drm: refuse a libdrmtap that cannot do the split export

The root --service must never load libEGL/libGLESv2: the point of the split is
that it exports the scanout dma-buf and the unprivileged --server converts. Two
paths could still break that, both because the loader accepted a library too
old to export.

drm_prewarm() called grab() when the loaded .so had no drmtap_grab_desc, and
grab() maps and detiles, so the privileged process pulled in the vendor GL stack
at startup, before any consumer had asked for a frame. The per-connection
capture loop then did the same for every frame, through the CPU fallback.

The version guard could not prevent it: it compared the ABI major only, and this
library is still 0.x, so every release it has ever made passed. Add a floor at
0.4.9, where the split entry points landed, and require the three split symbols,
which also rejects a build that reports a new enough version without carrying
them. That is not hypothetical: a pre-release stamped 0.4.15 shipped without the
multi-GPU accessors. Both refusals fall back to PipeWire/portal and say which
file and which symbols, at warn level.

The split symbols are no longer Options, so the type system carries the
guarantee instead of a convention. What is left of the CPU path is only what it
was meant to be: the consumer has no render node of its own, or the seat exports
no transferable dma-buf. Both are facts about the hardware, with no alternative
that keeps the stream, and neither is a property of which file was on the load
path.

Verified against the real library on i915. With 0.4.15 the export path captures
a tiled XR30 scanout and libEGL stays out of /proc/self/maps, while the old
grab() branch maps it, so the finding reproduces. A stub reporting 0.4.8 and a
stub reporting 0.4.15 without the split symbols are both refused, each with its
own diagnostic. The mirrored repr(C) layouts are unchanged across 0.4.9 to
0.4.15, checked field by field against include/drmtap.h at both ends, so the
floor costs no compatibility that was real.

* drm: move the _drm channel and its producer into src/ipc/drm.rs

src/ipc.rs is the file every unrelated IPC change has to be read through, and
this branch had grown it from 2227 lines to 4112. Move the DRM half out, into
the same #[path] submodule form the file already uses for ipc/auth.rs and
ipc/fs.rs, so it lands as ipc/drm.rs beside them.

What moves: the two payload structs, the producer that runs in the root
--service, and the bespoke SCM_RIGHTS framing the channel needs because
Framed/BytesCodec cannot carry ancillary data, plus their tests. What stays is
the Data variants, which belong to a shared enum and cannot live anywhere else,
and three re-exports so every existing call site keeps the path it already uses.

ipc.rs is 2285 lines now, 58 above upstream instead of 1885. The move is
content-identical: the only edits are the 39 per-item cfg attributes, redundant
now that the module is gated once at its declaration, and the test module cfg
that becomes a plain cfg(test). Checked by extracting the moved ranges from the
previous commit and comparing them line by line against the new file. Both
configs build with no new warnings and the same 92 tests pass, 14 of them the
drm ones that moved.

* drm: bound the _drm accept path (M1, M2, M8)

M1: authorization is now done on the blocking pool. It reads the active session
uid, which on a cache miss forks loginctl, and the socket is 0666 so any local
uid can make us do it. The same call exists for _service, but this runtime is
shared by every live capture stream, so a stall here hitches frames instead of
delaying one config sync.

M2: the handshake was a loop that ignored unexpected messages, which restarted
the ten second budget on each one, so a peer sending junk just inside the timeout
held a worker thread and one of the eight connection slots for as long as it
liked, and eight of them denied DRM capture entirely. It is one receive now, and
anything that is not DrmStart closes the connection: the consumer answers the
display list with DrmStart and nothing else, so there is nothing legitimate to
skip past.

M8: dropped the extra unauthorized-connection warn. log_rejected_service_connection
inside the authorization already logs the rejection with the peer and active uid
and rate limits it to one line per five seconds, which is exactly what a
world-connectable socket needs; the second line had no throttle and handed anyone
who can connect an unbounded log write.

Both configs build, 92 tests pass.

* drm: stop the two states that never settle (M4, M6)

M4: a dead producer left the availability verdict positive forever. The
background refresh keeps a positive verdict on a failed probe, which is right for
one failure and wrong for a run of them: if the root --service dies while this
--server lives, every probe fails, the cached list keeps being advertised, and
every display restart-loops. Three consecutive failures now drop the verdict to
Unknown, not to Unavailable, because the evidence is about the producer and not
about the hardware, so the next enumeration probes from scratch. The cold probe
also resets its own failure budget on success: it was never reset, so the five
strike allowance was spent once per process and a later probe demoted on its
first failure.

M6: a display that can never be grabbed churned PeerInfo about every 35 seconds
for the life of the process, because the cooldown was flat: demote, wait 30 s,
get advertised online, burn four sessions in a few seconds, demote again. The
cooldown now doubles per demote cycle up to 8 minutes. Recovery is unchanged in
the way that matters, since the count is erased the moment the display delivers a
frame rather than decaying with time, so a monitor that comes back is served
immediately.

Also, while changing that map: a zero-frame session on a display with no
connector identity was recorded under the empty key, which is the same aliasing
H2 removed for indexes, one unidentifiable display would have demoted the next
one. It is skipped now, as the comment above it always claimed.

Two new tests cover the backoff schedule and the reported 35 second cycle. 94
tests pass, both configs build.

* drm: give the DRM uinput update the timeout and the bookkeeping (M3, M6)

The DRM path sets the uinput absolute range itself, because it bypasses
check_init. That copy awaited update_mouse_resolution raw, and it was missing
three things check_init has sixty lines above it.

No timeout: uinput set_resolution reads its reply with no timeout of its own, so
a hung uinput socket blocked every video-service start on this branch, and wedged
the hotplug worker inside rt.block_on with UINPUT_REFRESH_BUSY latched true,
after which every later hotplug refresh was silently skipped for the process
lifetime. It is bounded at 3 s now, the same bound check_init uses.

No bookkeeping: it never called set_wayland_uinput_rect or
set_wayland_layout_baseline, which is why the #15601 layout-drift remap never
activated on the DRM path. Both are recorded now, and only after a successful
apply, so a transient failure is retried rather than remembered as applied.

No cache invalidation: the cached Wayland layout can predate compositor changes
made while no session was active, which is the case #15601 is about. Dropped
first, as check_init does.

It also stops reprogramming the device when the range has not changed (M6): a
display in a rebuild loop called this about once a second, and reapplying an
identical range is an IPC roundtrip plus a uinput reconfiguration under a user who
may be at the console. The layout baseline is still re-snapshotted on every call,
since it is what the client coordinates are measured against.

Left as a separate copy rather than folded into check_init: check_init ships in
every Linux build and the standing rule for this feature is that the drm-off
build does not change by a line. Both configs build, 94 tests pass.

* drm: check the greeter server is alive, not just spawned (M5, M10)

M5: the greeter fallback tested the wrong thing. start_server reports whether the
SPAWN succeeded, so a greeter account that cannot actually run the server, a
nologin shell or a hardened home, leaves a child that exits at once; the loop
sees only that the child is gone and respawns it as the greeter forever, never
reaching the root fallback, and the login screen becomes un-remotable on a host
where it used to work. It now requires the child to still be alive after a one
second grace before accepting it. A server that dies later than that is a
different, transient failure and the existing restart throttle already bounds it.

While there: the whole greeter branch is now inside the drm cfg, so the drm-off
build is upstream's single start_server line again rather than a run_as_greeter
variable that is always false.

M10: two monitors of the same model and resolution whose names do not normalize
to the compositor's matched no output at all, so both kept the DRM origin, which
is (0,0) for independent CRTCs. The client stacks them and injected coordinates
hit the wrong monitor with certainty. Unmatched connectors now take the next free
output in layout order, preferring one of the same physical size, and say so in
the log. That is at worst a swap of two identically sized rectangles, and the
layout stays coherent. The same pass also stops one output being claimed by two
connectors, which the unique-resolution rule allowed.

The assignment is now a pure function, so the cases are testable without a
compositor: five tests cover the naming difference, the identical-monitor case,
the double claim, name match beating the fallback, and more connectors than
outputs. 99 tests pass, both configs build.

* drm: stop reallocating and recopying whole frames (M9)

The CPU fallback moved a scanout four times: the producer packed it, the kernel
carried it, next_raw allocated and zeroed a fresh buffer to read it into, and the
consumer copied that into the slot. At 4K30 the last two are about 8 GB/s of
memory traffic that does nothing.

next_raw_into reads the body straight into a buffer the caller owns, so the
kernel copy lands where the frame is going to live, and resize costs nothing once
a buffer has seen one frame of that size. The frame buffers then circulate
instead of being freed and reallocated: whatever a new frame displaces goes back
on offer, both when the encoder consumes one and when a frame is superseded
before anyone reads it. The dma-buf path still copies once, because the convert
output is borrowed from the render context and only lives until the next convert,
but it copies into a recycled buffer and does it outside the slot lock, so a
multi-megabyte memcpy no longer holds the encoder off the slot.

Steady state is now one allocation for the whole session on both paths, and the
CPU path carries the pixels twice instead of four times.

The cursor body reads into its own buffer and is moved into the cursor cache
rather than copied; it is small and rare, so it stays out of the frame recycler.

Two tests: the raw body round trip now also covers a shorter body reusing the
buffer, so a stale tail cannot survive into it, and a new test asserts the frame
buffers circulate by allocation identity rather than by inspection. 100 tests
pass, both configs build.

* drm: the polish list, and a correction to my own ABI floor

The version floor I added two commits ago was one release too low.
drmtap_open_render and drmtap_convert_dmabuf are 0.4.9, but drmtap_grab_desc is
0.4.10, so a genuine 0.4.9 library passed the version gate and was then refused
by the symbol gate with a message that called it a stale or pre-release build,
which it is not. The floor is 0.4.10 now, the release where the whole split API
exists, and the test lists 0.4.9 among the rejected versions with the reason.

ExportLedger is deleted. DRM_FD_ELISION was false, so should_send_fd returned
true at its first branch and about sixty lines of eviction and epoch machinery
were unreachable, untested, in a security sensitive file. Why it was disabled
is worth keeping, so here it is: eliding the fd on an fb_id the converter has
already imported looks free, but the kernel can recycle an fb_id onto a
different buffer with identical geometry and modifier, and the exporter cannot
see the dma-buf inode that would tell the difference, so the elision can serve
a stale EGLImage. Sending it is cheap, the converter imports once per buffer and
closes the surplus fd, and libdrmtap's own cache keys on fb_id AND inode and can
only re-import when it is handed a real fd. That reasoning now lives here
instead of in dead code.

The rest:

- num_planes is clamped on the consumer before it reaches the C descriptor. The
  producer normalizes it and must be root, so this is only defense in depth, but
  the wire is the one place the value arrives from another process.
- warm_availability returns early on X11. Nothing there can consume a DRM
  stream, and probing makes the ROOT service open DRM readers, so an X11 host
  running a drm build was paying that at every startup for a path it can never
  take.
- drm_cursor_id no longer clones the cursor. The cursor service polls it at
  frame cadence to compare eight bytes, and a 256x256 cursor is 256 KiB.
- The premultiplied ARGB pass-through is now documented as matching the XFixes
  path, since that is why it is correct rather than an oversight.
- cfg hygiene: input_service.rs uses all(target_os = "linux", feature = "drm")
  like every other site, and active_uid_cached is gated with the feature too,
  which also removes a dead-code warning from drm-off Linux builds.
- Nits: DrmConn is pub(crate) like its constructors, new_drm_listener is no
  longer async with nothing to await, and the two anyhow! plus return Err pairs
  are bail! as the codebase writes them.
- DRM_CAPTURE_SECURITY.md moves to docs/ with the other docs, and its "no
  privileged child process is ever spawned" claim is corrected: an empty
  helper_path is not a disable switch in the C, find_helper searches six fixed
  paths and would exec one if the direct export ever failed. It is unreachable
  here for two independent reasons, the root service holds CAP_SYS_ADMIN so the
  direct path succeeds and the package builds no helper at all, and the paths
  are root-writable only, so the accurate statement is that this package never
  installs one, not that it can never happen.
- The comments that narrated the review rather than the code are rewritten to
  say what the code does. One of them had also drifted: the convert context is
  opened before we answer with DrmStart, not before the handshake.

Both configs build with no new warnings, 100 tests pass.

* drm: one DisplayHealth per connector, and the last index-keyed map

The three per-display verdicts are three answers to one question, can this
display be captured over DRM right now, and they already fed each other: the
rebuild cadence and the zero-frame streak end in the same demotion, and the
convert verdict is what keeps a multi-GPU display off the dma-buf path so it
never gets there. They are one struct now, keyed by connector identity.

This also closes a real leftover from H2. Two of the three maps were re-keyed by
identity then; the rapid-rebuild map was not, and stayed keyed by list index. A
hotplug that renumbers the list therefore moved a flap verdict onto whichever
monitor took that slot, which is the same defect in the third map. There is no
index-keyed per-display state left.

Behaviour is otherwise the same, with one improvement that falls out of the
merge: when a demotion cooldown expires, clearing the streak now keeps the
display's other state rather than replacing the whole entry, so a build cadence
and a convert verdict survive a retry the way they always should have.

One test for the demoted predicate, including that a higher demote count still
holds a display that a lower one would have released. 101 tests pass, both
configs build.

* drm: bound the GITHUB_TOKEN in the drm workflow

CodeQL flagged the new workflow for not declaring permissions, which is fair:
every job here only checks out, builds and tests, and the artifact up/download
in the deb job authenticates with the runtime token rather than this one, so
contents: read is the whole requirement. Declared at the workflow level so the
reusable bridge workflow it calls inherits the same bound.

The stock workflows do not declare it either, but they are upstream's and this
feature does not touch them; a new file can start out right.

* drm: make the outer handshake budget dominate the inner one

Two findings from the review bot on our own fork, both worth taking.

The caller waited HANDSHAKE_TIMEOUT_MS + 500 for the receive thread to hand back
the display list, but that thread is allowed to spend more than that: the connect
budget, and then recv_msg_timeout2 applies its argument twice in the worst case,
once waiting for the first byte and once for the body. So on a slow connect the
outer timer fired first and abandoned a handshake that was still inside its own
budget. The wait is now derived from those parts rather than written as a
constant, so changing either one cannot silently invert the relationship again,
and the two connect sites use the named constant instead of a literal.

The cursor cache insert shadowed hcursor under a cfg, so the same line meant the
requested id in one build and the served id in the other. It is a separate name
now, with the reason on it.

Not taken, and why: the bot also suggested making DrmCursorData carry width and
height as u32 to match the wire. They are i32 because that is what they feed,
protobuf CursorData declares both as int32 and platform/linux.rs assigns them
straight across. One cast has to exist somewhere, and it belongs at the boundary
where the values are already being validated, not at the consumer.

101 tests pass, both configs build.

* drm: bound the body read, and stop the empty key from aliasing displays

From the second review bot on our fork. Two of these are real and one of them is
mine from earlier today.

A raw body read had no deadline. Only the header was bounded, and drm_read_full
loops on readable() until it has the exact length, so a producer that wrote a
header and then stopped (crashed, stopped, wedged) pinned the consumer receive
thread forever. That thread is also the one that observes the stop flag, so every
capturer rebuild would have stranded another thread and its render context. The
whole body is bounded now, and an overrun is a hard error because the header is
already consumed and the frame cannot be resumed.

get_capturer_info collapsed an unknown connector identity to the empty string and
then read and wrote the health map under it, so two unidentifiable displays shared
one entry and one could demote the other. That is exactly the aliasing frame()
refuses to take part in; I fixed one side of it this morning and left the other.
The key is an Option now and both blocks skip when it is None: a display with no
identity simply carries no health.

Also from the same pass, smaller:

- build.py validates the shape of DRMTAP_SHA and DRMTAP_REPO before they reach a
  shell command. Both are env-overridable and get interpolated, and beyond the
  injection argument, an abbreviated sha would defeat the point of pinning while
  failing in a much less obvious place.
- the workflow's push path list is now identical to the pull_request one. It was
  missing four paths, so a push to master touching only those would have skipped
  re-verification.
- the checkouts set persist-credentials: false, so the token does not stay in
  .git/config for the rest of the job.
- a concurrency group supersedes a stale PR run, but never cancels a master run,
  whose whole purpose is to record that a commit was verified.

Not taken: reading VCPKG_COMMIT_ID and FLUTTER_VERSION from a shared .env. There
is no .env at the repo root, and the stock ci.yml and flutter-build.yml hardcode
those same two values, so this matches what is already there.

101 tests pass, both configs build.

* drm: test the half of the accept-time authorization that had none

The review called the accept-time authorization decision the single most
important invariant in this PR, and noted it has no test. Half of it did:
drm_peer_authorized_matrix covers the uid rule. The other half, the
/proc/<pid>/exe identity match that stops a DIFFERENT program running as the
right uid from being handed the screen, did not.

We said last round that testing it needs a second process with a different
executable, so it was integration rather than unit work. That was too
pessimistic: the negative case needs ANY foreign executable, not a second build
of rustdesk, and /bin/sleep is one. So the test covers all three outcomes: our
own pid matches, a live process running another binary is rejected, and a peer
whose pid cannot be resolved is rejected rather than admitted.

The test synchronizes on the child having exec'd before it looks. spawn returns
while the child is still a copy of us, and until exec completes /proc/<pid>/exe
points at OUR binary, so reading it too early sees a match and the assertion
passes for the wrong reason. It failed exactly that way under the parallel suite
and passed when run alone. A real peer has necessarily exec'd and connected
before it can be authorized, so the window exists only in the test.

102 tests pass, three consecutive full runs, both configs build.

* drm: make the refresh decision a pure function, and test it

The review named two untested things: the accept-time authorization decision,
covered by the previous commit, and the availability/demotion state machine. The
demotion half got tests with the backoff work; this is the other half, what a
completed background refresh decides.

It is extracted rather than tested in place on purpose. The effects touch
process-global state, DRM_STATE and the failure counter, which parallel tests
cannot share, so a test driving them would be intermittent by construction, which
is the kind of test nobody ends up trusting. The decision itself has no such
problem, so it is now a total function over the probe result and the consecutive
failure count, and the closure applies it.

Two tests: the decision table, including that a run short of the threshold keeps
a working verdict and the threshold gives it up; and the symptom the policy
exists for, a root service that dies while this server lives, where every probe
fails from then on and the verdict has to be given up in bounded time, to Unknown
rather than Unavailable, because what we learned is about the producer and not
about the hardware.

104 tests pass, both configs build.

* drm: count a display whose frames never match its advertised size

The display list carries the CRTC mode and a frame carries the scanout
framebuffer. Those are two different numbers whenever a CRTC scales a
smaller buffer up to its mode, so such a display fails the geometry guard
on the FIRST frame of every session, having delivered nothing.

That path marked the session as having produced frames, which is what the
zero-frame streak uses to decide a display cannot be served over DRM at
all. So the demotion to PipeWire never armed and the display rebuilt until
the rapid-rebuild guard caught it seconds later, under a message about a
mid-session change that never happened.

Count it instead, through the same bookkeeping the stream-died path uses
(now one helper, so the two cannot drift), and say which of the two cases
the error is. The unit test asserted the old behaviour on a capturer that
had never delivered a frame, so it is split into the mid-session case it
meant to cover and the first-frame case it was silently locking in.

* drm: make an unpinned libdrmtap deliberate, and reject --drm off Linux

Three ways to build a different libdrmtap than the pinned one (DRMTAP_REPO,
DRMTAP_SHA, DRMTAP_PREBUILT_DIR) were each silent, and the last skips the
sha verification entirely. The claim this feature rests on is that the
privileged capture library is the reviewed object at the pinned sha, so any
build that is not that one now has to say so: the overrides still work and
still cover local work and cross-builds, but they need
DRMTAP_ALLOW_UNPINNED=1 alongside them and the build prints what it did.

--drm on Windows or macOS was accepted and then dropped by get_features(),
so it produced a stock build that looked like a DRM one. Reject it.

Also test the _drm body-read deadline, which nothing exercised: the header
and the body are separate reads, so the caller budget does not cover the
second one and a regression there would silently reopen the stall.

* drm: treat an empty DRMTAP_PREBUILT_DIR as unset in the pin gate

build_libdrmtap_so() tests it for truthiness, so an empty value means no
prebuilt directory. The gate compared it against None instead, and would
have demanded the opt-in for an override that was never going to happen.

* drm: never latch the uinput refresh slot, and bound the source stride

The uinput refresh worker released UINPUT_REFRESH_BUSY on its two normal
exits only. The body locks several process-wide mutexes and does a Wayland
roundtrip, so an unwind there left the flag set for the process lifetime,
and every later hotplug then skipped the spawn and never reapplied the
uinput ABS range: the stale-range, wrong-output symptom the refresh exists
to prevent. This file already had the answer for the probe flag, one screen
away, and the hazard is called out in wayland.rs. Fixing one site and not
the other is the same miss as the hotplug maps.

The slot is deliberately handed back and re-taken mid-loop, so the guard
tracks ownership rather than releasing unconditionally: a plain RAII drop
would clear a flag a replacement worker owns.

drm_reader bounded only the destination (w*4*h) while the row loop reads up
to (h-1)*stride + w*4, so a large stride read past the mapping and could
overflow usize in y*stride. drm_render::convert already bounds stride*h;
the privileged half must not be the weaker of the two.

Also give the drm CI jobs a timeout, so a hung meson or vcpkg step fails in
an hour instead of six.

* drm: refuse to ship a libdrmtap built without the EGL backend

libdrmtap treats egl/glesv2 as OPTIONAL: without their headers and
pkg-config files meson silently builds a CPU-only stub. The stub still
exports every symbol the loader gates on, so nothing downstream notices,
and the split capture depends entirely on the unprivileged side
EGL-detiling the scanout it receives. The result is a build where DRM
capture quietly degrades to PipeWire on every tiled-scanout host, which is
most of them. Our CI asserts this on the .so it builds; a developer or
packager running build.py got no such check.

Assert on the artifact rather than passing -Degl=enabled: that option only
exists in libdrmtap past the pinned 0.4.15, and checking what was actually
produced also catches a stale or substituted object, which a build flag
cannot. Same two markers CI looks for, and for the same reason an ELF-level
check does not work: EGL is reached by lazy dlopen so there is no
DT_NEEDED.

* drm: gate the libdrmtap ABI on the minor, and skip the warm probe on X11

Two items from the review that I had recorded as done and were not.

The ABI check had a floor and no ceiling, so 0.5.0 and 0.9.9 passed. Under
0.x semver the minor is the breaking axis, and libdrmtap freezes only
drmtap_device and drmtap_dmabuf_desc: drmtap_frame_info, drmtap_display,
drmtap_config and drmtap_cursor_info are not frozen. A 0.5.0 adding one
field to drmtap_frame_info still reports major 0, so we would have loaded it
and read every field at the wrong offset, in the root service. It now
requires the verified minor; a 0.5.x needs a deliberate bump after comparing
the layouts.

The unit test asserted the opposite of this, in as many words ("0.5.0 must
pass"), so it was holding the hazard in place. Replaced.

warm_availability ran on X11 too, where every consumer of the verdict sits
behind an !is_x11() check, so the root service opened DRM readers for a path
the session can never use.

* drm: close the full-review findings (a third latched flag, and two escapees)

The one that matters: the display-cache refresh worker was the THIRD copy of
the wedged-flag hazard. catch_unwind covered only the enumeration, and
thread::spawn panics on EAGAIN after RUNNING was already swapped true, so
either path parked the flag for the process lifetime and every later refresh
- including every udev hotplug - returned early forever. Same ownership
guard as UINPUT_REFRESH_BUSY (the flag is handed back and re-taken mid-loop,
so an unconditional RAII release would clear a replacement worker's flag),
plus a fallible spawn whose failure drops the closure and releases the slot.
DRM_PROBE_IN_FLIGHT, UINPUT_REFRESH_BUSY, now this: the lesson stays
'grep for every site with the shape', and twice was not enough.

Two findings had been flagged in an earlier round and escaped the ledger:
- an unrecognized convert-output fourcc fell through to 'present as BGRA'
  with a debug log, where every sibling validation in that function is a
  hard error that lets the caller fall back to PipeWire. A 64bpp output
  passes the stride check and encodes garbage. Hard error now.
- the trust-boundary validation constants (fourccs, MAX_DIM,
  MAX_FRAME_BYTES) were declared independently on both sides of the split.
  Hoisted into drm_reader, imported by the converter, so the two halves
  cannot drift apart about what data they will touch.

The rest:
- the CI symbol extraction dropped any loader symbol containing a digit and
  degraded to a pass-with-zero-iterations no-op if the b"..." literals were
  ever refactored; digits allowed, count asserted, notice de-hardcoded.
- 'drm' in features was a substring test on the comma-joined string, so a
  future drm-lease feature would have shipped the consent-bypass deb
  without --drm. Exact membership now.
- the security doc claimed the deb is built on an ubuntu18.04 container;
  the only deb job runs on ubuntu-24.04. The 18.04 sentence now says what
  is true: 2.4.95 is an API floor, the binary floor is the build host's.
- DRM_DISPLAY_CACHE poison handling was recover-in-the-writer,
  panic-in-the-readers; both readers now recover like the writer.
- the producer prewarm ran on X11 where no consumer can connect, the same
  inconsistency just fixed for warm_availability. The listener still starts
  (the service outlives sessions; a later Wayland login must find the
  socket), only the prewarm is skipped.

* drm: measure the verification deb glibc floor and put it in the artifact name

The workflow already said in a comment that this deb is a verification build
with a higher glibc floor than the release debs, because it builds on the
runner rather than in the ubuntu18.04 container the stock job uses. A comment
in this file is not visible to whoever downloads the artifact from the Actions
UI, and the name was a bare rustdesk-unattended-wayland-x86_64.deb, so it read
like something installable anywhere.

The floor is now read off the built object with objdump and goes into the
artifact name, so the constraint travels with the file. Measured rather than
stated: a hardcoded number would drift the next time the runner image moves.
Verified the pipeline against a real deb here (2.39).

Restoring the container build is the other option and is cheap to do -- the
recipe including the two 18.04 traps is still in this repo's history -- but it
belongs with a deb that is actually distributed, not with a job whose contents
are already asserted in-place.

* drm: the same latched-flag bug a fourth time, in my own fix for the third

I built UinputRefreshGuard INSIDE the spawned closure, so it only covered
paths where the closure ran. thread::spawn panics on EAGAIN after the swap,
so no guard existed and the flag stayed set for the process lifetime, which
is the exact failure the guard was introduced to prevent. I then wrote
RefreshSlot correctly - constructed before the spawn, moved in - two hours
later and did not go back to fix its sibling. Both are right now, and the
spawn is fallible in both.

Also from the review:

- DRMTAP_PREBUILT_DIR returned before the EGL-stub assertion, so the check
  only guarded the source build. That is backwards: prebuilt-dir is the
  widest override (no fetch, no sha check, an object this script never sees),
  the likeliest to hand over a stub, and the path our aarch64 cross-build
  actually uses. Verified the assertion accepts a real .so and rejects one
  built with -Degl=disabled.
- convert() bounded only the frame libdrmtap returns, not the descriptor going
  in. offsets/pitches address plane ranges inside the dma-buf, so those are
  what a malformed pair would reach past. Bounded per populated plane, the
  same way the export side is. Defense in depth (the producer is
  root-authenticated and libdrmtap validates against the fd since 0.4.12),
  but the two halves should agree before the C sees the data, not after.
- the flutter patch step used '[[ test ]] && git apply' as its last command,
  so the step would FAIL rather than skip the first time FLUTTER_VERSION
  moves off 3.24.5. Explicit if/else, and the values now come from the
  environment instead of ${{ }} interpolation, which also clears zizmor's
  template-injection warning. Checked both branches.

Declined: the cursor id/cache-key convergence finding. Both accessors use one
selection over one map, so they can only disagree across a publish race, and
state.hcursor is already set to the id ACTUALLY served (drm_served_id), which
is the sync the finding asks for - added in an earlier round.

* drm: stop routing gates from paying for the availability probe

A Major finding I skipped twice, and the file already argued against itself:
wayland.rs's own NOTE says re-probing _drm from the async enumeration path
blocks the executor long enough to trip 'deadline has elapsed' and spiral
into a restart loop -- and then six routing gates called is_available(),
which runs query_displays() inline whenever the state is Unknown (cold start,
or a NEGATIVE_TTL expiry mid-session). ensure_inited, is_inited,
get_displays_and_primary and clear() are exactly the paths the NOTE names.

is_available_cached() is a single mutex read: KNOWN-available or not. The six
gates use it, which is safe because they are routing decisions, not
capability ones -- a cold cache answers 'not DRM' and the caller takes the
PipeWire path it would have taken anyway.

Switching all seven, which is what the finding literally suggested, would
have introduced a worse bug: warm_availability calls query_displays()
directly, so is_available() would have had ZERO callers and nothing would
ever probe lazily again. A --server that started before the root service
would then never see DRM for the rest of its life. get_capturer_for_display
keeps the probing form -- it is sync, on the plain video thread, it is the
capture-build path where a definitive answer is the point, and it is what
makes a cold cache recoverable.

* drm: stop leaking the authorized _drm fd into forked children

libc::dup() does not copy the close-on-exec flag, so the dup'd _drm socket fd
was inherited by every child this process forks. This process is the ROOT
service and it does fork synchronously elsewhere (the loginctl active-uid
lookup), and that fd is an ALREADY-AUTHORIZED channel to the one thing on the
box that hands out scanout dma-bufs. F_DUPFD_CLOEXEC instead. Measured the
difference rather than assuming it: dup() leaves FD_CLOEXEC clear,
F_DUPFD_CLOEXEC sets it.

Also the last two artifact sources without the stub check:

- --package + --drm stages the .so straight out of a bundle somebody else
  produced, with no _assert_so_has_egl. Third source, same exposure as
  DRMTAP_PREBUILT_DIR, now asserted like the other two. All three artifact
  paths are covered.
- the workflow triggers omitted src/server.rs, src/server/input_service.rs and
  src/platform/linux.rs, which all carry DRM wiring (warm_availability, the
  cursor path in run_cursor, the producer start and get_cursor/get_cursor_data),
  so a PR touching only those skipped the entire drm verification. Added to
  BOTH mirrored lists and asserted equal (15 == 15).

* drm: decide x11 inside the prewarm, with a bounded re-check

the one-shot is_x11() gate at the call site misfired during boot:
get_display_server() falls back to "x11" while loginctl cannot name the
seat0 session yet, so on a wayland host with the service enabled at boot
the prewarm was skipped for the life of the service and only ever ran
after a manual restart, which is how every deploy happened to exercise
it.

move the gate inside drm_prewarm and re-ask every 2s for up to 30s. a
genuine x11 or headless host exhausts the budget having opened no
DrmReader and no drm fd; a wayland boot proceeds as soon as the session
reads as wayland. measured on a boot: the skip used to fire 0.8s in
while loginctl reported the wayland greeter in that same second, and
graphical-session.target only arrived at +5s.

* drm: wake idle-disabled displays and settle the topology before the client is promised a list

a compositor that idles long enough does not merely blank a panel: it
disables the connector, leaving no scanout for any capture backend to
read - not drm, not pipewire, not x11. on an unattended box that meant
connecting to whatever was still scanning out (on an apple t2, the
60x2170 touch bar strip) with the real panel sitting disabled next to
it, or a stale cached list advertising a display with nothing behind it
("waiting for image").

the fix has three parts, and where the wake runs is the load-bearing
one:

- the root service answers every _drm handshake with a fresh, settled
  enumeration (drm_enumerate_settled): enumerate, and if a CONNECTED
  display has no crtc, inject one synthetic 1px pointer round trip over
  uinput (rate limited to one per 20s, one winner via compare_exchange)
  and hold the answer until nothing wakeable is left undriven or a 3s
  deadline passes. rate-limited losers wait for the outcome too while a
  wake is recent - answering with the pre-wake list is exactly the
  mid-transition state that produced duplicate, misindexed monitors.
  connectors a wake could not bring back are latched by connector
  identity (device:connector) and the latch is self-refuting: an entry
  later seen scanning out is dropped, so one slow modeset cannot
  disable the wake for the life of the service, and a dummy plug cannot
  suppress the wake for a different panel that idles later.

- the login path refreshes the cached display list over a live
  handshake (refresh_displays_for_login) before peer info is built, so
  the list the client is promised is the post-wake truth and never
  changes under it seconds later. the publish is generation-checked
  against concurrent writers; every failure mode keeps the previous
  cache, so a login can never get harder than before, only truer.

- the capture handshake resolves the display index the client chose by
  connector identity against the handshake list (the service enumerates
  fresh per connection, so an index alone is only meaningful against
  the list it came from), fails the build cleanly when that monitor is
  gone, and no longer republishes its handshake list into the
  availability cache - that unordered write could clobber a newer
  settled list with pre-wake data and re-advertise a reordered list
  under a live session.

the display-list read timeout grows to cover the settle budget
(DISPLAY_LIST_TIMEOUT_MS), or a wake that needs the full recheck would
turn into a spurious handshake timeout on exactly the host it exists
for. removing the display cache from the handshake path also retires
DRM_CACHE_WARMED; the cache still feeds the topology push and the udev
listener.

measured on the t2 (amdgpu panel idle-disabled, appletbdrm touch bar
still scanning out): connect -> wake fires with undriven=1 -> panel
returns in ~330ms -> the same probe answers 2 displays -> the client
starts on the panel. with the panel awake: zero wakes. the root service
still never maps libEGL/libGLESv2.

* drm: close the round-7 review findings

- the renumbering probe in the DrmDisplaysChanged handler now reads the
  pushed list at wire_idx, the slot our monitor held in the service's
  index space, instead of at the index the client chose. the pushed
  list shares the handshake list's construction, so probing the client
  index compared two different index spaces whenever a wake or hotplug
  had renumbered entries - tearing down a healthy stream or missing a
  real renumbering.
- both message-body reads (cpu frame, cursor pixels) now run under a
  deadline. only the header read re-checked `stop`, so a producer dying
  between a header and its body pinned the receive thread forever and
  every rebuild leaked a thread plus its render context.
- the drm cursor cache gets a size ceiling (drm ids are derived from
  the shape's content, so an animated pointer minted a new key per
  shape and the map grew for the life of the service; x11 ids come
  from a small serial set, so the ceiling is gated and the stock build
  is untouched).
- has_non_drm_backed_display reads a two-scalar accessor instead of
  cloning and geometry-augmenting the whole display list on every
  cursor tick.
- the libdrmtap pin validation moved out of import time into
  build_libdrmtap_so(), so leftover DRMTAP_* environment variables or a
  malformed sha cannot fail a stock build that never touches libdrmtap.
- reworded a workflow comment whose literal expression marker broke
  actionlint.

* drm: close the round-8 review findings

- the .so contract check in the drm workflow runs under strict mode:
  without set -e the trailing ::notice echo returned 0 and masked the
  `test "$missing" -eq 0` assertion, so the step passed even with a
  missing loader symbol or a CPU-only stub. the two extraction
  pipelines get an explicit rescue so a zero-match grep still reaches
  the ::error guard that explains WHY instead of dying silently.
- the pipewire-fallback geometry guard no longer compares the physical
  drm size against the portal rect on a single-display host: the rect
  is the compositor's LOGICAL size, so on a scaled output the two
  legitimately disagree (2880x1800 vs 1440x900) and the guard rejected
  the one valid fallback, restart-looping the display instead of
  degrading. on a single-display host the whole-desktop stream is that
  display by construction, so only the position has to agree; the size
  check stays on multi-monitor hosts, where it is what tells one
  connector apart from the full-desktop rect.

* drm: close the round-9 review findings

- strict mode on the remaining two assert steps of the drm workflow
  (the deb-contents assert and the glibc-floor measurement): same
  masking pattern as the .so contract step fixed last round - without
  set -e only the last command's status counts and the mid-script
  checks were decorative. the floor extraction gets an explicit rescue
  so a no-match grep still reaches the `test -n` reporter.
- the security doc states the whole accepted version window (exactly
  the pinned minor with a patch floor; a NEWER minor is refused too,
  because the mirrored struct layouts are only verified against the
  pinned one), and the auditing section carries the command matching
  its leftover-object comment.
- the uinput-missing warning literal lost the embedded space runs a
  reflow had left in it (it is the sole, once-per-process diagnostic
  for that failure and it read as a run-on line with gaps).
- the geometry-mismatch path in frame() hands the taken buffer back to
  the recycler before erroring; dropping it made every rebuild cycle
  re-allocate a scanout-sized buffer.

* drm: document the display wake in the threat model

the wake is deliberate input injection by privileged code, which is
exactly the kind of thing this document exists to state precisely
rather than leave to be discovered in the diff: why it must run in the
root service (uinput is root-only and the compositor holds drm master),
what it can reach (only an already-authorized _drm connection triggers
it), how narrow the trigger is (a connected-but-undriven connector,
with a self-refuting per-connector memory for the hopeless ones), the
rate bound (one wake per 20s process-wide, single winner), the device
lifetime (created and destroyed around the emit), and that a host
without /dev/uinput loses nothing it had (such a session was already
view-only).

* drm: close the round-10 review findings

- the /dev/dri gate returns the CANONICAL path instead of a bool, and
  both callers open that value. answering yes/no meant the caller
  handed the original string to libdrmtap, which re-resolved every
  symlink component after the check - a check-then-use window, in the
  root service. this is the whole point of the gate, so it should
  never have been able to hand back an unresolved path.
- `--package <folder> --drm` builds the capture library instead of
  demanding it inside the bundle. no build path puts libdrmtap in a
  bundle folder (the flutter deb builds it straight into the staged
  deb), so that check made the flag combination impossible to satisfy.
  the safety property it stood in for is now asserted directly and
  better: the staged BINARY must carry the drm dlopen path, so a stock
  binary can never be packaged under the consent-bypass name. a bundle
  that does carry a .so keeps its existing EGL assertion, and the
  variant naming keys on the explicit request rather than on what
  happened to be staged.
- the deb assert step globs into an array and asserts the count: under
  set -e `ls` aborted before its own `test -n` could report, and
  several matches produced a multi-line value whose mv failed with an
  unrelated error.

* drm: finish the logical-geometry comparison, and chain a re-raise

the pipewire-fallback guard now normalizes BOTH sides to logical before
comparing. last round fixed only the single-display case, which left
the same defect on the shape that actually has it: on a multi-monitor
scaled host the advertised geometry carries the PHYSICAL drm mode plus
the compositor scale, while the portal rect is already logical, so a
scaled output disagreed with itself (2880x1800 against 1440x900) and a
per-connector stream that really was that display was rejected,
leaving it advertised offline instead of degrading. the size check
itself stays: on a multi-monitor host it is what tells one connector
apart from the whole-desktop rect. the failure message reports the
logical numbers, the ones actually compared.

also chains the libdrmtap read failure with `from err` so the original
OSError survives (ruff B904).

* drm: fix two review-suggested changes that were wrong, and stop overclaiming in the docs

an adversarial sweep over the whole batch, aimed at the failure that
kept recurring here (a hazard identified and only some instances
fixed), found that two changes made on review advice were themselves
defects. both are reverted with the trace written down so they do not
get "fixed" again:

- the hotplug renumbering probe reads the pushed list at the CLIENT
  index again, not the service one. `bound_to` is an IDENTITY,
  (device, crtc_id), so comparing it against a slot is not a
  cross-index-space comparison; and `swap_available_displays` installs
  that same list as DRM_STATE two lines later, which IS the client
  space - display_service re-advertises it, input is mapped through
  it, the next rebuild reads `expected` out of it. Probing the service
  index answered a question nothing downstream consumes and went quiet
  in exactly the case the guard exists for: a stream whose wire_idx
  differs from its client index kept running while that index came to
  mean another monitor, so the client rendered monitor A believing it
  was monitor B and routed every click accordingly.
- the pipewire-fallback guard compares raw sizes again. BOTH sides are
  physical: `Display::width()` on the wayland variant returns
  `physical_width()`, and `try_fix_logical_size` only repairs the
  capturable's separate logical_size field. Scaling the drm side
  therefore compared logical against physical and rejected the valid
  stream on precisely the scaled outputs it was meant to rescue. The
  single-display carve-out now needs BOTH sides to be single, since a
  monitor on a card the service cannot open is missing from the drm
  list while the compositor still drives it.

also from the sweep:

- a capture build whose index is out of range of the advertised list
  now fails instead of falling back to the raw index, which the wake
  can have grown the service list back past - that bound a second
  video service to a monitor already being served and recorded its
  health under the wrong identity.
- the security doc no longer claims the privileged process never loads
  GL. That is true of the DEFAULT path and measured there, but the CPU
  fallback converts in-process, and a tiled scanout can only be
  decoded through the GPU, so libdrmtap dlopens libEGL in the calling
  process when the frame needs it. The doc now says which property
  belongs to the path and which to the process, and bounds the cases
  instead of overclaiming.
- the wake latch is described honestly: it self-clears when the
  display is next driven by anything, but nothing retries it, so a
  transient failure can leave it latched on an unattended host.
- the wake's uinput device DECLARES two axes and BTN_LEFT (libinput
  ignores a device that does not look like a mouse) while EMITTING
  only the net-zero axis round trip. the doc said one axis and no
  keys, describing the emit as if it were the declaration.
- the drm CI never ran for a change to the root Cargo.toml, where the
  top-level `drm` feature is defined, or to Cargo.lock, which every
  `--locked` build here resolves against. both triggers list them now.
- the deb assertion checks the packaged BINARY carries the libdrmtap
  dlopen path, not just that the library was staged beside it.

* drm: close the round-13 review findings

- the ABI refusal message has a branch for an unverified MINOR. It had
  only two, so a library NEWER than the pinned minor was told it
  "predates the split-capture API" - the opposite of its problem, and
  the kind of message that sends someone looking in the wrong place.
  the warn line names the accepted minor too.
- the libdrm floor no longer claims 18.04 ships 2.4.101: base bionic
  shipped 2.4.91, which is BELOW the 2.4.95 the GetFB2 API needs, and
  only the updates/HWE stack clears it. read as "18.04 with updates,
  or newer".
- the drm-build marker scan reads the staged binaries chunked inside a
  `with`, overlapping by len(marker)-1 so a marker cannot fall across
  a chunk boundary, instead of pulling a 45 MB librustdesk.so into
  memory and leaning on refcounting to close the file. verified
  against a real drm build (found) and an unrelated binary (not
  found).

* drm: close the round-14 review findings

- the .so contract and deb assertions no longer pipe into grep. under
  `set -o pipefail`, `producer | grep -q` reports a FALSE FAILURE once
  the producer outruns the 64 KB pipe buffer: grep -q exits at the
  first match, the producer dies on SIGPIPE, and pipefail makes that
  the pipeline's status - so a library that HAS the symbol is reported
  as missing it and the step fails on a good build. measured on a real
  EGL-enabled .so (101 KB of strings, both markers present): the piped
  form reported both missing. this was introduced by the strictness
  fix two rounds ago and only passes today because a release-sized .so
  fits in the buffer. NOTE the obvious repair does not work either -
  materializing the output and piping the variable keeps the pipe and
  fails identically (measured), so these now match with bash's own
  pattern operator and no subprocess at all. verified with positive
  and negative controls.
- warm_availability decides X11 for itself, inside its retry loop,
  with the UNMEMOISED `scrap::is_x11()`. this is the same one-shot-at
  -startup bug the pre-warm had, in its sibling call site, left behind
  when that one was fixed: the check ran during startup, where
  loginctl cannot yet name the seat0 session and the answer defaults
  to "x11", so a Wayland host that came up slowly skipped the warm for
  the life of the process and got back the cold-probe "No displays"
  symptom the warm exists to remove. the memoised form would have
  moved the bug rather than fixed it, since it latches its first
  answer.
- the grab_desc SAFETY comment says what the frame protocol actually
  is instead of promising a release on every return path: traced in
  the C, a failing grab_desc leaves nothing to release (-EINVAL
  returns before allocating, a failed inner grab has already cleaned
  up, and -ENOTSUP releases the frame itself), so releasing on those
  paths would be a double free.

* drm: bound the work an unauthenticated peer can make the root service do

the `_drm` socket is world-connectable by design (the unprivileged
--server has to reach it), and every accepted peer got a spawn_blocking
authorization - which forks `loginctl` whenever the active-uid cache
misses - BEFORE any admission bound applied. MAX_DRM_CONNS does not
help there: it only counts peers that already passed. So a local uid
that will be rejected could still open connections in a loop and keep
the shared blocking pool busy, and that pool is shared by every live
capture stream, which is exactly the stall the comment above the
authorization warns about.

add a separate, small in-flight bound around the authorization step,
deliberately NOT the same counter as MAX_DRM_CONNS: sharing one would
let a rejected flood eat the capacity the real consumer needs. the
guard is taken before the spawn and released as soon as the verdict is
in, so the slot covers the authorization only. the rejection logs at
debug rather than warn for the same reason the existing rejection is
silent - anything reachable by any local uid must not be an unbounded
log-write primitive. unit-tested like its sibling, including that the
pre-auth bound stays the tighter of the two.

* drm: reject an out-of-range num_planes on the import side instead of clamping it

the incoming descriptor's plane count was clamped to 1..=4 for the
validation loop but passed to libdrmtap RAW, so a wire descriptor
claiming 7 planes was checked as if it had 4 and then handed over
claiming 7. the pinned libdrmtap refuses >4 itself, so this was not an
overflow today - but the stated purpose of that block is that the two
halves of the split agree about what they will touch BEFORE the C sees
it, and that only holds if the count travelling with the descriptor is
the count this side bounded. it also stops this half depending on an
internal check in a library pinned from another repo.

reject and normalize instead, which is what the EXPORT half already
does in grab_desc; the two sides now have the same shape.

* drm: close the round-17 review findings

- the scanout dma-buf fd is duplicated with F_DUPFD_CLOEXEC. `dup(2)`
  never copies close-on-exec, so this fd was inherited by every child
  the ROOT service forks (it forks synchronously for the loginctl
  active-uid lookup) - and what this fd names is the live screen
  contents. this is the SAME defect already closed on the `_drm`
  socket fd in ipc/drm.rs; fixing that one and not grepping for the
  siblings is how this survived. there is exactly one dup in the drm
  path now and it is this one, verified by grep. measured that
  F_DUPFD_CLOEXEC sets FD_CLOEXEC and preserves the O_RDONLY access
  mode the read-only export depends on; SCM_RIGHTS delivery is
  unaffected since the receiver gets its own descriptor.
- Desktop::refresh resolves HOME on the login-Wayland path too, since
  the drm build now starts a --server as the greeter uid there and a
  child with no HOME has nowhere to put its config. the compositor
  variables stay blank deliberately: the drm path talks to the root
  service and a render node, never to the compositor or the portal,
  which is why it works at a login screen at all. reasoned, not
  measured: a current GDM runs its greeter as `gdm-greeter`, which
  `is_gdm_user` does not match, so that path is not reachable on our
  hardware - measured there, the greeter server gets a fully populated
  environment through the branch below.
- the glibc-floor step globs into an array and asserts the count, like
  its sibling assert step. that sibling was fixed two rounds ago and
  this one was left behind.

* drm: put the display wake behind its own compile gate and a runtime option

everything else in this backend READS: it captures a scanout. the wake
WRITES, injecting one synthetic pointer event from the root service
into the user's session. that is a different kind of operation and it
should be switchable on its own, at both levels.

- compile: a `drm-wake` feature on top of `drm`. every wake-only item
  is gated and drm_enumerate_settled has two definitions, so
  `--features drm` builds the same capture path with no wake code in
  the binary. verified on a RELEASE artifact with both controls: the
  drm markers are present (Started drm ipc server) and the wake string
  is gone. the unattended deb passes drm-wake, so answering an
  objection is one word in build.py rather than a revert.
- runtime: `enable-drm-display-wake`, server-side, the same shape
  rustdesk already uses for the closest thing it does to this
  (keep-awake-during-incoming-sessions, which PREVENTS sleep where
  this RECOVERS from it, and is acquired only once a connection
  exists, which is too late for a host that cannot be reached).
  the `enable-` prefix is load-bearing: option2bool reads an absent
  value as ON, and a host whose screen went dark is the case the
  unattended package exists for. set it to "N" and the service stays
  read-only with respect to input.

the key is declared in this file rather than in hbb_common's `keys`
module, where rustdesk's own option constants live: hbb_common is a
submodule of a repo we do not control, so a constant there could only
land after an upstream change plus a submodule bump. the option system
reads by string, so registration is not required; the cost is that the
key is set in the config file rather than the settings UI, which is
how an unattended host is configured anyway.

* drm: enumerate /dev/dri by path instead of trusting one auto-detected card

when `list_devices` gives us nothing to work with, the fallback was a
single auto-detected reader. that is the wrong unit of enumeration on a
multi-card host, and the reason is worth keeping: libdrmtap's
auto-detect picks a card that is SCANNING OUT, so when the interesting
display is asleep it picks a DIFFERENT card and we enumerate only that
one. the asleep display is then invisible - not as a display, and not
as an undriven connector either, which is what the wake keys on.

measured on the t2 with the panel idle-disabled, through a direct
libdrmtap call: auto-detect succeeds and binds card0, the touch bar,
because the touch bar is what is still scanning out; the 2880x1800
panel on card2 is invisible to that reader, while opening card2 by
explicit path in the same instant reports `eDP-1 crtc=0 active=0`
exactly as needed.

so walk /dev/dri/card* and ask each, with auto-detect demoted to a last
resort for the case where no card opens by path. this path is reached
only when list_devices is unavailable (a pre-0.4.15 .so) or opened
nothing, so it costs nothing on the normal path - it is defensive, not
a fix for anything observed with the pinned library.

the enumeration result is logged UNCONDITIONALLY, including the empty
case, because a silent "found nothing" gives no way to tell an empty
host from a failed enumeration.

* docs: state the per-frame reauthz and the wake's one-shot bound

Two things the security doc left implicit, both measured on 2026-07-31.

The `_drm` authorization is described as per-connection, which undersells it.
DRM/KMS capture is not session-scoped - it grabs the physical scanout of a CRTC
no matter which session owns the display - so the check is re-run on every
frame, and when a user logs in at a greeter the greeter's stream is closed
rather than continued. That is the property that stops an outgoing greeter
process from capturing the screen of the user who just logged in, and it is
worth stating where a reader is looking for exactly that confinement.

And the wake section never said what happens after the wake. It resets the
compositor's idle timer; it does not hold the display on. Left alone, the
connector idles off again one full idle period later: 30.3 s at a GDM greeter,
70.3 s in a user session with idle-delay=60. Saying so makes the existing
"useless as a way to keep a screen lit" clause concrete, and points at the
component whose job that actually is.

* drm: ship the wake in the CI deb, and assert the artifact on both package paths

Three findings from the round on the wake-gate commits, all the same shape: the
gate made "what was asked for" and "what was produced" diverge, and two places
still trusted the first.

CI built the unattended-wayland deb with `--features ...,drm` and then packaged
it with `--skip-cargo`. build.py appends `drm-wake` for `--drm`, but skipping
cargo means whatever that explicit line compiled is what ships, so the deb had
no wake code in it at all while being named and documented as the variant that
has it. The feature list has to be complete on the line that actually builds.

The marker assertion that catches exactly this class only guarded one of the two
packaging paths. `build_deb_from_folder` asserts that the staged binary carries
the libdrmtap dlopen path before it takes the unattended-wayland name; the
flutter path did not, and `--skip-cargo` reaches that one. A stock binary could
therefore be packaged under a name that conflicts with and replaces the stock
package, and then never capture. Hoisted the check to module level and called it
from both, before the bundle is renamed.

And the security doc described the synthetic input injection as an unconditional
property of a drm build. It is behind its own compile feature and a runtime
option, which is exactly what an operator auditing the deb needs to know.

* drm: stop a delivered frame from erasing the two verdicts it says nothing about

A deep review pass over the whole branch, run because a maintainer once found
two bugs here that nineteen rounds of an automated reviewer had missed. Three
findings, two of them the same root cause, all confirmed by re-reading the code.

The first frame of a session dropped the display's whole health entry. That is
right for the zero-frame streak, which is exactly the verdict a delivered frame
refutes, and wrong for the other two:

- `last_build`/`rapid_builds` exist for a display that delivers a first frame
  and then fails downstream every cycle. Wiping the cadence on that frame meant
  the flap guard could never reach RAPID_REBUILD_MAX in the one case its own doc
  comment describes. It was a guard that could not fire.
- `prefer_cpu` records which GPU exports a monitor, a property of the host, and
  is documented as following the monitor for the process run. Erasing it on the
  first frame it made possible meant every rebuild re-paid a dead dma-buf
  session: fail, learn, take the CPU path, forget, fail again. It never demotes,
  because the CPU session clears the streak each time, so it repeats for the
  process lifetime. Worse, the bit is set on the recv thread and was deleted on
  the encoder thread, so a convert failure racing a queued frame could destroy
  it inside the very session that learned it.

So reset only the streak. Only a topology change, where the GPU mapping really
can have changed, may still clear the convert verdict.

Second, `get_primary_index` was a second, weaker copy of the connector-to-output
matcher: name-only, with neither the unique-resolution step nor the layout-order
fallback the augmentation grew. On a compositor whose names do not normalize to
the DRM names it answered 0 while the geometry augmentation had matched that
display to a different output, so the advertised primary and the advertised
geometry disagreed. It now asks the same assignment, which makes them agree by
construction.

Third, packaging asserted half of what the deb claims. `assert_staged_binary_is_drm`
looked for the libdrmtap dlopen path, which `--features drm` alone also carries,
so a bundle built without `drm-wake` could still be named and documented as the
variant that wakes an idle-disabled display; it now requires the wake marker too.
And nothing anywhere checked that the libdrmtap being shipped is one the runtime
would accept: `abi_accepted` is the only validation of the pinned version and it
runs at dlopen time on the user's machine, so the pin and the gate could drift
and every existing assertion would still pass -- EGL markers say nothing about
the version, the CI symbol contract never calls drmtap_version(), and the deb
regex matches any version. Staging now applies the gate parsed out of the Rust,
so a green build cannot produce a deb whose capture can never start.

* drm: fix the ABI cross-check's path, and stop panicking on a failed spawn

The ABI cross-check added in the previous commit could never run: both callers
of stage_libdrmtap_into_deb chdir into flutter/ first, and the check opened
drmtap_dl.rs by a path relative to the cwd, so every --drm packaging run died
with FileNotFoundError. CI caught it. It is anchored on __file__ now, and read
through a context manager.

Worth naming why the test missed it: the check was exercised from the repository
root, which is the one directory where the bug is invisible. A control that does
not reproduce the call site's conditions is not a control.

Three more, all the same class the previous commit was already fixing - a
hazard closed at one site and left at its siblings:

- `std::thread::spawn` panics when the thread cannot be created, and the panic
  unwinds into whoever called it. The two hardened workers used Builder; the
  five remaining DRM threads did not. The startup ones now log and degrade (a
  lost pre-warm costs one cold probe, a lost udev listener costs the mid-session
  push, a lost warm costs the first session), and the two per-session ones live
  in functions that already return ResultType, so they fail that one connection
  cleanly instead of unwinding through the handler.
- The wire descriptor's `num_planes` was clamped to 1..=4 here while
  `drm_render::convert` rejects an out-of-range count on purpose, so that the
  count the C reads is the count this side validated. Clamping made that reject
  unreachable: a descriptor claiming 7 planes arrived as 4 and passed. The two
  guards were added by different review rounds and had been quietly cancelling
  each other. The raw value is passed through now, leaving one validation site,
  next to the code that dereferences it.
- A SAFETY comment claimed the cursor is released only on success. It is
  released on every path after a successful get_cursor; only a failed get_cursor
  returns without releasing, because then there is nothing to release. The
  release protocol is the reason that block is unsafe, so the comment describing
  it has to be right.

* drm: convert the last panicking spawn, and resolve geometry outside the lock

The spawn conversion in the previous commit missed one. `query_displays` still
used `std::thread::spawn`, which panics when a thread cannot be created, and it
is reached from both `get_capturer_info` and `warm_availability` - so the panic
would land on the capture-build path rather than being reported as the failed
probe every caller already handles. There are now none left in the two DRM
files.

Worth writing down how it survived a pass whose whole purpose was to find it:
the previous commit enumerated the siblings with a grep piped through `head`,
there were eleven matches, and `head` printed ten. The one it cut is the one
that was missed. Same shape as a build log read through `tail` and a `find`
given `-xdev`: the tool truncated the survey and the survey looked complete.
When enumerating sites for a class fix, do not pipe the enumeration.

Also, `get_capturer_for_display` resolved the advertised DRM geometry while
holding the `CAP_DISPLAY_INFO` read guard. That lookup runs a compositor output
roundtrip, and `clear()` takes the write guard on every capturer teardown -
which is what is happening when a display is demoted or flapping, i.e. exactly
when this path runs. The value does not depend on anything inside the guard, so
it is resolved before taking it.

And the security doc listed the unattended package's `Conflicts`/`Replaces` but
not its `Provides: rustdesk`, which is the field that lets a third-party package
depending on `rustdesk` be satisfied by the consent-free variant. An operator
auditing that metadata needs all three.

* drm: test that a delivered frame keeps the cadence and the convert verdict

The guard this locks in could never fire before: a delivered frame dropped the
whole DisplayHealth entry, which took last_build/rapid_builds with it, and those
exist precisely for a display that delivers a first frame and then fails
downstream every cycle. prefer_cpu went the same way, erased by the first frame
it had made possible.

The test drives the real frame() path through the existing harness rather than
simulating the bookkeeping, and it was checked against the old behaviour: with
the entry removed again it fails on "the entry must SURVIVE a delivered frame".
A test that has not been seen failing is not evidence.

* drm: bound the two waits a peer could hold open in the root service

A review pass over the privileged side, reading src/ipc/drm.rs as a local
unprivileged attacker. Two findings, both confirmed by tracing every link.

The wire had a deadline in one direction only. Every read has been bounded since
the beginning, and next_raw_into even carries the argument for it: a peer that
writes a header and then stops pins the other end forever on a readiness wait.
The write side had no deadline at all. That asymmetry costs more here, because
the parked task is in the root service: a peer that simply stops reading - a
kill -STOP on its own --server, a ptrace stop, a frozen cgroup - leaves the send
blocked inside the forward loop, so the loop top is never reached again. The
credit stall, the per-frame reauthorization and the topology-generation check
all live at that loop top, and the connection slot, the worker thread and its
DRM context stay pinned until the peer chooses to resume. drm_write_all is the
single funnel for both directions, so one deadline there covers every send; the
consumer's frame-ack write had the same shape and gets the same bound.

And drain_frame_acks looped until WouldBlock, which is a promise the peer gets
to keep. It is synchronous on the single-threaded _drm runtime, so a peer that
writes a continuous stream instead of one ack byte per frame keeps the receive
queue non-empty, never yields, and pins that thread at 100% CPU - starving every
other stream on it, which on a multi-monitor client means one connection wedging
its own siblings. Capped per call, with an early return once the credit budget
is full; anything left stays queued for the next pass.

Three comments were describing a mechanism that no longer exists. Two still said
a delivered frame drops the whole health entry, which stopped being true when
that was narrowed to zeroing the streak; the third, written in that same change,
pointed at drm_clear_prefer_cpu, a function deleted several commits earlier. The
convert verdict having no clearing site is correct and now says why: it is keyed
by connector identity, so a monitor that moves to another GPU arrives under a new
key and starts clean.

Also, the new regression test held the process-wide health mutex across its
assertions, so the one failure it exists to report would have poisoned that mutex
and buried itself under unrelated PoisonErrors in its sibling tests. It copies
the record out and releases the guard first, as the module's own helper does.

* drm: clear the stale _drm entry by fd, and fix three comments that argue backwards

new_drm_listener cleared the stale socket with std::fs::remove_file, which is
unlink(2). Against a directory-typed squatter that returns EISDIR and leaves the
entry in place, and endpoint.incoming() then fails EADDRINUSE, so DRM capture
falls back to the portal for the rest of the boot over an entry we could have
removed. The _service listener has never had that hole: it removes entries
through a no-follow fd on the parent directory, fstatting the entry first and
choosing AT_REMOVEDIR when it needs to. That helper now takes a path instead of
a postfix, so the _drm listener - which deliberately stays outside hbb_common's
postfix machinery - can use the same one on the directory it just hardened. The
precondition is narrow (an unprivileged process has to win the creation race
before the root service first hardens the dir on a fresh boot), which is why the
failure is a warn and not a bail.

Three comments stated their reason backwards or more strongly than the code
supports. None of them changes behaviour; all three would send the next reader
to verify the wrong thing.

The wake's 20 s rate limit was justified as being short enough to be useless as
a way to keep a screen lit. That is inverted: a shorter gap would make relighting
easier, not harder, and 20 s is below every idle period we have measured (30.3 s
at a greeter, 70.3 s in a session). What actually bounds it is that the wake is
one-shot, which the next sentence of the same doc already says. Fixed at both
sites, the constant and the security doc.

The doc block above drm_enumerate_settled reads as one paragraph but spans a cfg
split, so its shared contract and the wake-less specialisation looked like one
statement about the arm below it. Marked explicitly.

And get_primary_index claimed its answer agrees with the advertised geometry by
construction, which is true only where augment_with_wayland_geometry runs the
same assignment - it declines below two connectors or two outputs, and in that
band the two functions run different code. The answer is still never worse than
the documented fallback there, and now the comment says which.

* drm: test that the fd-based removal clears a directory squatter

The regression this pins is the one the previous commit fixed: a stale entry in
the IPC parent directory is not necessarily a socket, and unlink(2) refuses a
directory. The test asserts remove_file fails on it FIRST, so a passing run
cannot be vacuous, and it checks the second call succeeds too, since this runs
before every bind.

Confirmed red against a neutralised helper before being kept.

* drm: fix what the previous commit's own comments got wrong

A review pass over 08d311d60 - the commit whose stated job was correcting three
comments that argued backwards - found that four of its replacements were wrong
in turn. Two independent passes agreed on each. This is the correction.

The 20 s gap paragraph was never attached to the constant. It is the first
paragraph of a doc block that runs on to OPTION_ENABLE_DRM_DISPLAY_WAKE, so it
documented a config-key string, while DRM_WAKE_MIN_GAP three lines below had no
doc at all. That misplacement predates the previous commit; expanding the
paragraph from one line to five without noticing does not. Moved onto the
constant.

Its content was also wrong for the second time. Saying the limit is not what
stops a screen being held on was right; naming the one-shot property as the
thing that does bound it was not. One-shot cannot bound a repeated relight when
the permitted repeat interval is shorter than the idle period, which is exactly
what the sentence before it establishes: 20 s against a measured 30 s. An
authorized peer that keeps reconnecting can have the panel relit shortly after
each idle-off, and what makes that acceptable is the authorization itself - root
or the active session's own uid, who can hold their screen on with
systemd-inhibit and need nothing from us. Both the constant and the security doc
now say that, and the constant carries a note not to write the old claim a third
time.

The shared contract of drm_enumerate_settled sat on the arm the shipped build
compiles out. build.py --drm adds drm-wake, so a maintainer opening the real
function found it undocumented while a doc comment marked "shared" hung off its
dead twin. A doc comment cannot attach to two cfg arms, so the shared part is
now a plain comment above both and each arm keeps a short doc of its own.

get_primary_index claimed a sole compositor output is matched to the lowest
connector. It is not: pass 1 matches by normalised name and by unique resolution
before any layout-order fallback, so the answer in that band can be any index.
The conclusion survives - a name match is better evidence than a blind 0 - but
the reason given for it was false, and the reason is what the next reader uses.

And the directory case is narrower than it was written. AT_REMOVEDIR is rmdir,
so what the previous commit closes is the EMPTY squatter; a non-empty one still
returns ENOTEMPTY and still blocks the bind. Left that way on purpose - the cure
would be root recursively deleting a tree an unprivileged process planted in a
world-writable directory - and now stated at all three sites plus pinned by the
test, which also stops claiming to cover the call site it does not reach.

* drm: say less in these comments, since saying more keeps being wrong

Third pass over the same comments, and the third set of errors in them. The
pattern is not that any one sentence was careless, it is that every additional
explanatory sentence is another falsifiable claim, and the ones that keep
failing are the ones that reach past what the file can support. So this is
mostly deletion: net fifteen lines fewer.

The "SHARED CONTRACT" header was wrong about its own first paragraph. That
paragraph describes waking, waiting and a rate-limit race, none of which the
wake-less arm does - and the previous commit went further and pointed the
wake-less arm's own doc at it, so that arm now claimed to do the thing the very
next line said it does not. Only the second paragraph, on why an idle-disabled
output is the trigger, is genuinely common to both. That stays above the pair as
a plain comment; the wake behaviour moves onto the wake arm, where it is true.

DRM_WAKE_MIN_GAP no longer argues about why unbounded relighting is acceptable.
It named the wrong actor: the _drm peer is always our own unprivileged --server,
while the party whose reconnects drive the relight is the remote client, which
is neither root nor the local uid and cannot inhibit anything. The constant now
states what it bounds and what it does not, and stops there. The security
document makes the acceptability argument instead, and makes it about the right
party: a peer already authorized to watch that screen gets it lit, which is
visible to a person standing there, not additional access.

Two narrower ones. The helper said a non-empty squatter yields a named error
"instead of" EADDRINUSE; the caller gets both, and the sibling comment in the
listener already said "ahead of", so the same commit disagreed with itself. And
get_primary_index claimed the two functions disagree across the whole band where
augmentation declines, which is false for zero outputs and for a single
connector - it now names the one case that matters.

Not touched, and pre-existing: MAX_DRM_CONNS's doc block has the same wrong-item
defect (it opens on a function and ends on the cap), and drm_enumerate_all_displays
runs two paragraphs together. Both predate this branch's comment work and neither
belongs in a commit about it.

* drm: give the send deadline one budget for the whole write, not one per wait

The earlier commit put the timeout inside the loop, so the budget restarted on
every iteration. A peer that accepts a byte just inside each window, or that
keeps the socket flapping back to WouldBlock, re-arms it forever and the root
task stays parked exactly as it did before - which is the stall the constant's
own doc says it bounds. The diagnosis was right and the fix did not implement
it. Both send paths now take one deadline before the loop and wait with
timeout_at.

Swept the rest of the file for the same shape. The credit wait re-arms a 1 s
poll on purpose and is fine: its total bound is CREDIT_STALL, measured at the
loop top from credit_since, and its comment already says the deadline is
enforced there and not in the poll. That is the pattern the write path was
missing. The read paths are single-shot bounded, not loops.

Not covered by a test. Reproducing it needs a peer that accepts a little data
just inside each window, so the scenario runs longer than the 5 s budget itself
and a no-progress peer - the case a simple test would build - times out
correctly under both the old code and the new.

* drm: stop claiming the wake-less build cannot inject input

It can. Dropping drm-wake removes injection from the CAPTURE path and nothing
else: start_os_service calls start_uinput_service unconditionally, with no
feature gate, so the root service runs RustDesk's keyboard and mouse uinput
backends on every build, drm or not. That is how remote control works on
Wayland and is not ours to change - but a maintainer auditing "is the injection
path present in this build?" was being told no by a comment in the file most
likely to be read for that question. The line now says what is actually true of
the capture path and points at the ungated call, so the next reader is not sent
to verify the wrong claim.

The sentence is inherited: it came in with 2648ad0a2 and survived two review
rounds because both were reading the comments I had just CHANGED, and this one
I only re-wrapped. Re-wrapping is re-asserting.

Also narrowed the wake arm's "the wait applies to every handshake that saw an
undriven display": four early returns skip it - option off, nothing wakeable, no
uinput, no recent wake to settle - and the same block asserts the first of them
four lines later, so the paragraph contradicted itself. And "the trigger" in the
shared block lost its antecedent when the wake paragraph moved onto the wake
arm; it is "the signal" now, which is true for both arms.

* drm: put two doc blocks on the items they describe

Both pre-existing, both found by walking every doc run in the file down to the
item it attaches to rather than by reading prose.

handle_drm_conn's description was stranded: the block opened on the function and
ended on the connection cap, so it attached to MAX_DRM_CONNS while the function
itself had no doc at all. Moved the function's paragraph onto the function; the
cap keeps its own.

And drm_enumerate_all_displays ran its enumeration paragraph and its return-value
paragraph together with no separator, so they read as one. Blank doc line between
them. No text changed in either case - this is placement only.

* drm: pin the send deadline with a test, and close five review findings

The send deadline had no test, and I had written down that it could not have
one: a peer that never reads times out correctly under the broken per-wait form
too, so the obvious test proves nothing. That is true and it is not the whole
answer. A peer that DRIPS separates them, and the first version I wrote still
did not - draining a kilobyte at a time never makes the socket writable again,
because Linux asserts POLLOUT on a stream socket only once a decent fraction of
the send buffer is free, so the sender saw one long readiness wait and both
forms timed out identically. At 64 KiB the socket really does re-arm and the two
diverge. Measured both ways: the test passes in five seconds against the fix and
fails at twenty against the per-wait form, with the message it exists to print.
The chunk size is documented in the test for exactly that reason.

Four more, all verified against the code before touching it:

grab()'s SAFETY block claimed the frame is "released on every path". The ret < 0
arm returns without releasing, because a failed grab_mapped leaves nothing to
release. Its two siblings, grab_desc and cursor, already state the distinction
precisely; this was the loose copy, and the release protocol is the reason the
block is unsafe in the first place.

drmtap_dl.rs still said minor bumps are additive and compatible. abi_accepted
requires an exact minor match and the block below it explains why, so the file
argued both sides and the stale half is an invitation to widen the gate.

grab_desc validated width, height and plane count but not pitch or offset, while
the converter bounds pitch * height + offset per plane. Same bound on the export
side now, so both halves refuse the same descriptors - the principle grab()
already states. No pixel access happens there, so this is not an out-of-bounds
fix; it keeps a bogus pitch off the wire and puts the rejection on the side that
can name the device.

And the deb staging interpolated so_path unquoted, which breaks on a path with a
space (DRMTAP_PREBUILT_DIR is user-supplied).

Also covers the regular-file case through the new removal helper - the stale
socket every restart hits, which the existing file test reaches by another path.

* build: quote the rest of the path interpolations, not just the two that were named

The previous commit quoted so_path and stopped there, which left the six shell
commands that build libdrmtap interpolating src and build_dir bare. Both derive
from repo_root, which is built from __file__, so a checkout under a path with a
space splits the argument and git init, git remote add, git fetch, git checkout,
meson setup and meson compile all fail with an error that says nothing about the
real cause. Same defect, same fix, and quoting one pair while leaving its
siblings is the shape a reviewer finds next.

* fix(drm): close the review items on the capture backend

Guard the producer thread, surface a swallowed spawn error, stop the CI
feature list from drifting from build.py, and four smaller ones.

Should-fix:

- `start_os_service` started the DRM producer with a bare `thread::spawn`,
  the one spawn in this feature that was not built with `thread::Builder`.
  `spawn` panics if the thread cannot be created (EAGAIN under a thread or
  memory limit), and that panic unwinds out of `start_os_service` and takes
  the root service with it -- for a feature whose failure should only cost
  DRM capture. Builder + warn, like the other four.

- `refresh_available_async` dropped the spawn result on the floor. There is
  no wedge (the single-flight guard moved into the closure and is dropped
  with it), but a refresh that can never start was invisible: the cached
  verdict just keeps being served past its TTL. The sibling spawn already
  logged; now both do.

- The drm workflow hardcoded the cargo feature list because it packages with
  `--skip-cargo`, so `get_features()` in build.py and the CI line were two
  definitions of the same thing and only the drm/drm-wake half was asserted
  afterwards. Adds `build.py --print-features`, which prints the list those
  flags select and exits, so CI asks instead of repeating; the same flags now
  drive the compile and the packaging. The step asserts the answer really is
  a drm build before handing it to cargo, matching whole comma-separated
  tokens so a future feature merely containing "drm" cannot satisfy it.

Smaller:

- The ENOTSUP fallback in `drm_capture_worker` switched to the CPU path
  without clearing `stalled`, so stalls charged to the dma-buf path could
  trip MAX_STALLED early and close a connection the fallback was about to
  serve.

- `FrameSlot` kept one recycled buffer and claimed at most one is idle at a
  time, which does not hold: the receive path supersedes an unconsumed frame
  while the encoder returns its borrow, and those two writers do not even
  share a lock, since the receive path takes a buffer and publishes in two
  separate acquisitions. The later write freed a scanout-sized allocation the
  recycler exists to keep. Two slots is the exact bound for three in-flight
  buffers. The existing test passed against this, so the new one counts the
  offers rather than asking whether any came back.

- `get_cursor`/`get_cursor_data` use the memoised `is_x11()` while the
  capture path deliberately uses the unmemoised `scrap::is_x11()`. That is
  the right trade at cursor cadence, since the unmemoised form forks
  `loginctl` per call -- say so, because the surrounding code argues the
  opposite for its own callers.

* docs(drm): cut the changelog prose out of the comments

Removes passages that document this patch's own revision history rather
than the code, including the four quoted in review. Deletions and one
misplaced comment moved to the field it describes; no comment was
reworded, so nothing here can state something new.

- `drm_capturer.rs`: the `drm_clear_prefer_cpu` parenthetical (that
  function does not exist), "same mistake, same shape, as the two flags
  before it" (it names no identifier, and both sites it gestures at carry
  their own hazard comments), and "the comment was right and the code used
  the probing accessor anyway".
- `drmtap_dl.rs`: "this test replaces one that asserted the opposite", and
  "that sentence used to live here" -- the instruction not to widen the
  gate on the strength of "minor bumps are additive" stays, since that is a
  live constraint rather than history.
- `platform/linux.rs`: the "NOT REPRODUCIBLE ON OUR HARDWARE" provenance
  label. What it introduced survives and is the better form of the same
  warning: on the test host `is_gdm_user` does not match `gdm-greeter`, so
  that branch is dead there and the code is for display managers whose
  greeter user does match.
- `ipc/drm.rs`: "and that sentence has already been wrong here twice". The
  warning it trailed stays, because a shorter gap really would make
  relighting easier and the constant should not be described as bounding
  how long a screen stays lit.
- `build.py`: "the answer to an objection is one word, not a revert".

Also moves the comment describing `cur` off `display`, where a field
reorder had left it sitting above that field's own comment.

Most of the remaining density is mechanism, measurement or a hazard, and
is left alone: the pipe/SIGPIPE analysis, the physical-vs-logical rect
comparison, the `wire_idx` vs `display` argument, the wake measurements
(REL_X alone did not wake the panel; the device bind window), the
F_DUPFD_CLOEXEC privilege-leak argument, and the SAFETY blocks.

* docs(drm): condense the capture comments from 35% of lines to 6%

The five DRM files were 2319 comment lines against 4181 of code. The rest
of this repository runs at 3%, so they were roughly twelve times the
surrounding density, and that was the fair reading of the review: the
volume itself is what makes an 8k-line addition hard to review.

They are now 302 lines. What went is rationale: alternatives considered
and rejected, arguments for why a design is acceptable, restatements of
what the next line of code plainly says, and the same fact repeated at
several sites.

What stayed is what a reader cannot recover from the code, kept to one or
two lines each:

- every SAFETY comment on an unsafe block (none was dropped)
- ownership and release contracts with the libdrmtap C API, including
  which grabs own a frame and which must not release it
- ordering requirements: announce a pending refresh before claiming the
  single-flight slot, take the busy flag before the spawn rather than
  inside the closure, never hold DRM_STATE while taking a per-display map
- the flow-control protocol, both ends of it
- wire-format and units conventions, and the cmsghdr alignment the
  control-buffer type exists to provide
- measured facts, reduced to the measurement: which synthetic events wake
  an idle panel and which do not, and the device bind window
- hazards on the world-connectable listener, including why the rejection
  paths log at debug or not at all

No code changed: with comments and blank lines stripped, all five files
are byte-identical to their previous contents. Tests are 111 in the
rustdesk crate and 20 in scrap.

* docs(drm): restore the wire_idx argument on the hotplug guard

The condensation cut this one too far. Within minutes of the shortened
version going up for review, a reviewer read the remaining line and
proposed changing the probe from `display` to `wire_idx` -- which is the
change that was already tried here and was wrong.

So the argument is not rationale prose, it is what stops a plausible and
incorrect edit to a guard in the capture path, and it goes back in at six
lines: `bound_to` is an identity rather than a position, the swap below
installs this list as the client-space DRM_STATE, and probing `wire_idx`
would go quiet in precisely the case the guard exists to catch.

* docs(drm): correct what an empty render_node means on the wire

The condensed doc said "Empty = auto-select", which is false on the host
that field exists for. `drm_capture_worker` computes
`ambiguous_gpu = render_node.is_empty() && render_node_count() > 1` and
folds it into `force_cpu`, so an unnamed exporter on a machine with
several render nodes takes the CPU path rather than auto-selecting. It
auto-selects only where there is a single node.

* docs(drm): fix comment claims that do not match the code

An audit that verified every comment claim against the CODE (rather than
against the pre-condensation text, which is what the earlier pass did)
found twenty that were false or unqualified. Some came from the
condensation dropping a qualifier; several predate it.

The ones that mattered most:

- `drm_render.rs` said libEGL/libGLESv2 are loaded "never in the
  privileged root service". That is true of the split path only: the CPU
  fallback calls `drmtap_grab_mapped`, whose auto-process step reaches
  `drmtap_gpu_egl_convert` in the CALLING process. `DRM_CAPTURE_SECURITY.md`
  already documents this precisely, and `drm_reader.rs` already said "on
  this path"; this one comment had lost the qualifier.
- "A miss is fail-closed" on the per-frame reauthorization: true for a
  non-root peer only, since `drm_peer_authorized` returns true for uid 0
  before it compares against the active session.
- The cursor body check was described as a no-op because the hidden
  sentinel supposedly arrives 0x0 with an empty body. It arrives 1x1 with
  four bytes, so the check is live.
- "EVERY write to DRM_STATE goes through here": the TTL restamp writes
  directly, and the comment on that arm says so.
- `open(crtc=0)` was described as selecting the "primary" CRTC; libdrmtap
  picks the first CRTC with a valid mode, and in that library "primary"
  names a plane.
- `list_devices() == None` was described as leaving the caller on
  single-device auto-detect; the caller scans /dev/dri/card* itself.
- The framing note claimed the whole channel is length-prefixed; the
  reverse-direction frame acks are bare bytes.

Also corrects `buffer_id`, which was documented as the producer's stable
pool key: it is fb_id tagged with a per-connection epoch and no consumer
reads it today.

No behaviour changes. One executable line is touched: the message string
of a unit-test `assert!` that asserted the auto-select claim being
corrected here.

* docs(drm): fix the second primary-CRTC occurrence the audit flagged

Same correction as the enumeration-side comment: libdrmtap auto-selects
the first CRTC with a valid mode, and primary names a plane there. The
audit had flagged both sites and only one was fixed.

* feat(drm): move the libdrmtap pin to 0.5.2 and the ABI gate with it

libdrmtap 0.5.2 is now on rustdesk-org, so the pin can move. It fixes the
padded-framebuffer read: a scanout whose pitch exceeds width*bpp was
decoded at the wrong stride, which is why the Touch Bar strip on an Apple
T2 produced no image and was listed as a known limitation.

The three parts have to land together, and build.py enforces it: the
staged .so is cross-checked against the ABI constants parsed out of
drmtap_dl.rs, so a pin without the gate (or a gate without the pin) fails
the build rather than producing a deb whose capture can never start.

- pin: cbc5e6af5 (0.4.15) -> 653de8c (0.5.2), in build.py, which is the
  single source of truth, plus the informational version comment in
  libs/scrap/Cargo.toml.
- gate: DRMTAP_ABI_MINOR 4 -> 5 and the patch floor (4, 10) -> (5, 0).
  0.4.x is now refused even though it carries the whole split API, because
  of the stride bug above.
- the newer-minor rejection test now derives its cases from
  DRMTAP_ABI_MINOR rather than hardcoding 5, so the next bump cannot leave
  it asserting that the newly verified minor must be refused. That is
  exactly what the hardcoded list would have done here.
- DRM_CAPTURE_SECURITY.md: the vetted window is now 0.5.x with x >= 0.

Verified: the build fetches 653de8c by sha and meson produces
libdrmtap.so.0.5.2, which the runtime gate accepts. Tests 111 in the
rustdesk crate, 20 in scrap.

* fix(drm): refuse --drm on the packaging paths that cannot honour it

Blocking finding from review. `get_features()` gated only on `windows or
osx`, but Linux has four packaging branches and only the deb one is
drm-aware. On a host with pacman, yum or zypper, `--drm` compiled in
`drm,drm-wake` and then packaged through a path that does not bundle
libdrmtap, does not rename, adds no Conflicts/Provides and never runs
`assert_staged_binary_is_drm()` -- emitting a package NAMED `rustdesk`
carrying the consent-bypass backend and the root-side uinput injection.

The distinctly named package is the informed consent this feature rests
on, so those branches now refuse the flag instead. `linux_packaging_branch()`
mirrors the elif chain in main() and is the single place that decides,
so the check cannot silently disagree with the branch actually taken.

Also from the same review:

- the bare-soname dlopen fallback is no longer offered when running as
  root. It exists so an unpackaged development build can load a locally
  built .so, but it was also the one place where which file happens to be
  on the ld.so path decided what gets mapped into the CAP_SYS_ADMIN
  process. The packaged service finds the absolute path first regardless,
  and a root process that reaches the fallback has no bundled library at
  all, which is the PipeWire-fallback case rather than a reason to search.
- `rm -f {so}` is quoted, like the neighbouring `cp` already was.
- `Cargo.lock` is dropped as a CI path trigger. Measured over the last 100
  commits it alone would have fired this workflow 13 times and the pair 24
  times, each about two job-hours of vcpkg + flutter release build, almost
  always for a dependency the drm path never touches.
- `abi_gate_rejects_a_library_from_before_the_split` no longer implies the
  patch floor is what refuses those versions; the minor mismatch is. The
  floor is vacuous by construction while it sits at patch 0 of the
  verified minor, so a second test asserts exactly that and turns into a
  tripwire the next time a floor lands mid-minor, as (4, 10) did.
2026-08-06 12:20:57 +08:00
fufesou
f5ab01f8bd fix(clipboard): win, populate file formats (#15692)
* fix(clipboard): win, populate file formats

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

* fix(clipboard): prevent Windows file clipboard OOB access

* reduce diffs to master

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

* comments

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

* fix(clipboard): win, OOBs and double free

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

* fix(clipboard): win, check deep copy

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

* comments

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

* fix(clipboard): harden Windows clipboard memory handling

- clear HGLOBAL aliases after ownership transfers
- validate callback inputs and capability sets
- bound file-content responses and close search handles on errors

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

* fix(clipboard): harden Windows cliprdr memory safety

- validate clipboard descriptors and response sizes
- fix allocation ownership and cleanup paths
- synchronize format-map access across callback and STA threads
- prevent clipboard format TOCTOU races

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

* Comments on stale remote file formats

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

* fix(clipboard): check pointers before using

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

* fix(clipboard): harden Windows COM error handling

- roll back FORMATETC enumeration on deep-copy failure
- keep the enumerator constructor internal
- propagate IStream seek and read failures

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

* explicity `WIN32_FIND_DATAW`

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

* fix(clipboard): validate format data size and simplify lock cleanup

Reject clipboard data exceeding UINT32_MAX before allocation and
keep format-map cleanup and lock release within the owning function.
Add boundary tests for response data sizes.

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

* fix(clipboard): missing frees

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-06 11:16:01 +08:00
Mariano Abad
cc85685b96 fix(linux): stop losing every inhibitor when the ScreenSaver name is absent (#15772)
On Linux, keeping the host awake during an incoming session asks keepawake for
three things at once: the display through org.freedesktop.ScreenSaver on the
session bus, and idle plus sleep through logind on the system bus. keepawake
takes the ScreenSaver one FIRST and abandons the whole request if it fails, and
WakeLock::new discarded the error with .ok(). So on any session where that name
is missing, RustDesk silently holds NOTHING - not the display inhibit it could
not take, and not the logind inhibits it never got to. On a host whose logind
IdleAction is not the default, that means the machine can suspend in the middle
of an active remote session, with any capture backend.

The name is missing on a GNOME login screen. Measured on a GNOME/Wayland GDM
greeter: org.freedesktop.ScreenSaver answers "was not provided by any .service
files" and cannot be activated, while org.gnome.SessionManager is on the same
bus and its idle inhibit works there. Same machine, same state: with it held the
output was still lit at 129.9 s of idle, without it the compositor disabled the
output after 30.3 s. Disabled, not blanked - an idle compositor releases the
CRTC, so there is no scanout left for anything to read.

So on the failure path, take both halves separately instead of neither:
- ask keepawake again without the display part, which restores the logind
  idle/sleep inhibits that have nothing to do with the missing session name;
- and get the display half from whichever session interface this desktop has,
  trying org.gnome.SessionManager and then org.freedesktop.PowerManagement.

Only the failure path changes: a session where the ScreenSaver inhibit works is
untouched. Where no session interface answers, the log now names every one that
was tried and the error each returned, which is the whole diagnostic for a
desktop nobody here can test on.

Verified on a GNOME/Wayland greeter with a live client: the inhibit is taken
86 ms before anything else happens on the connection, and appears to
gnome-session as "RustDesk: incoming session (idle)". The PowerManagement entry
is NOT verified - it is the interface KDE and XFCE implement, it costs one extra
failed call where it is absent, and the log is what will tell us whether it is
the right one.
2026-08-06 10:58:23 +08:00
RustDesk
7eb9150116 Audit retry nonce (#15759)
* fix: retry audit posts and add per-record nonce

A single post_request attempt meant any transient failure (timeout,
DNS, connection reset) silently dropped the audit record. Retry up to
3 times with backoff and log at error level when a record is finally
dropped.

Retries (and the existing TCP-proxy fallback) can deliver the same
record twice; attach a per-record nonce so the api server can dedup.

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

* fix: fail audit posts on http error status

post_request discards the status code, so a 5xx from a reverse proxy
(e.g. nginx answering 502 while hbbs restarts) or any 4xx rejection was
treated as success and the audit record silently dropped without a log
line. Add post_request_with_status (same semantics and TCP-proxy
fallback as post_request, status preserved; existing callers untouched)
and use it for audit posts: 2xx succeeds, transport errors and 5xx
retry, 4xx fails immediately since retrying a deterministic rejection
cannot help.

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

* fix: report audit posts rejected with 200 error body

hbbs maps handler failures (e.g. a database write error) to HTTP 200
with an {"error": ...} body (WebError::ServerError), so the client
treated them as success and the audit record was silently dropped.
Detect the error body and fail visibly. No retry: the server already
consumed the nonce, and persistence failures are the server's job to
solve; the client's job is to make the loss visible.

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

* fix: give audit retries a delay long enough to outlive a restart

The backoff was 1s then 2s, so all three attempts landed within about three
seconds. That does not cover the case the retry exists for: a reverse proxy
answering 502 while the api server restarts fails fast, so every attempt hits
the same outage and the record is dropped anyway.

Use 10s and 30s instead. The window is bounded on the other side - the api
server dedups by nonce for five minutes, and a retry arriving after that
expired would be stored twice - so the worst case is now about three minutes,
leaving room under that limit.

Derive the attempt count from the delay table so the two cannot drift apart.

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

* fix: retry audit posts the server answered with an error body

hbbs reports handler failures as 200 with an {"error": ...} body, and this
treated them as final on the grounds that the server had already consumed the
record's nonce. That is no longer how the server behaves: it releases the nonce
when the write fails, and answers a post whose earlier attempt is still being
written with an error as well. Both are exactly the cases where trying again is
what gets the record stored, so giving up after the first attempt drops audit
records the retry was added to save.

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

* fix: bound audit retries by elapsed time, and retry 408 and 429

The comment claimed the retry window fit inside the server's five-minute nonce
memory with room to spare, and that was wrong: one attempt is up to 84s, not
12s, because post_request_ retries the TLS handshake up to four times at 12s
each before the 36s TCP-proxy fallback. Three of those plus the delays is 292s
against a 300s window, and a suspend between attempts stretches the wall clock
without any bound at all, so counting attempts cannot bound this. Stop by
elapsed time instead: no new attempt starts past 120s, which leaves the last
one room to finish well inside the server's window.

Also retry 408 and 429. Both are transient - the request timed out upstream, or
a proxy is shedding load - but the 5xx test dropped the record after the first
attempt.

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

* fix: only an empty 2xx body counts as a stored audit

The success check was inverted: any 2xx body that failed to parse as an
{"error": ...} object was reported as stored. A proxy interposing a 2xx
maintenance page, or a malformed error value, therefore ended the retry loop
with success and silently dropped the record - the exact loss the retry was
added to prevent.

The audit handlers' success contract is an empty body, so treat exactly that as
success. A nonempty body with a valid error message stays a retryable server
error; any other nonempty body is now a retryable "unexpected response body"
instead of an accepted store. Both old and new hbbs answer success with an
empty body, and no caller reads the returned text, so nothing depends on the
previous acceptance.

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

* fix: do not start an audit retry past the deadline

The deadline was only checked after an attempt returned, so an attempt could
still begin up to one backoff delay past it - starting as late as ~150s and
landing at ~234s, while the comment claimed no attempt starts past 120s.
Re-check after the delay so the stated bound actually holds: the last attempt
now starts before 120s and lands by ~204s, inside the server's five-minute
nonce window with margin restored.

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

* docs: drop a retry rationale the server no longer backs

The comment claimed hbbs answers a post whose earlier attempt is still being
written with an error, so that retrying it is what stores the record. That
stopped being true: hbbs now answers a concurrent duplicate as already stored
rather than as retryable, having dropped the in-flight rejection along with the
claim state machine it needed.

Nothing in the handling changes - a 2xx carrying an {"error": ...} body is
still retried, and that is still right, because the server releases the
record's nonce when its write fails. Only the half of the rationale the server
no longer backs is gone, since this comment is where the contract between the
two repos is written down.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:57:09 +08:00
Alex Rijckaert
ef3a57580f Update Dutch translation (#15767)
* Update Dutch translation

* Update src/lang/nl.rs

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

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-05 16:51:07 +08:00
fufesou
402ed07b0c fix: Harden Windows installer temp command scripts (#15634)
* fix: Harden Windows installer temp command scripts

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

* fix: restore stop-service after install preparation failure

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

* fix(windows): preserve special characters in installer paths

Handle carets and exclamation marks safely across cmd.exe parsing stages.
Add coverage for special-character paths in the elevated installer handoff.

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

* fix: installer, validate app name

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

* update tests

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

* Simple refactor

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

* Simple refactor

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-04 14:29:04 +08:00
Fadouse
3cf32e7066 fix(wayland): scale portal pointer coordinates on niri (#15683)
* fix(wayland): scale portal pointer coordinates on niri

* perf(wayland): cache portal scaling desktop check
2026-08-04 14:18:41 +08:00
fufesou
6f1eb164d6 fix(clipboard): validate files (#15693)
* fix(clipboard): validate files

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

* fix(clipboard): address file validation review feedback

- remove unreachable empty-prefix test assertions
- name the shared COM/LPT prefix length
- document non-atomic path validation behavior

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

* update hbb_common

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

* fix(clipboard): reject traversal in file descriptors

- reuse parser validation for outgoing descriptor names
- propagate descriptor serialization errors
- add regression coverage for parent path components

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

* fix: clipboard, validate file name length

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

* fix: clipboard, comments

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

* fix(clipboard): support multi-root file selections

Use each top-level path's parent as its relative root so file
descriptors remain safe and relative across different directories.

Add regression coverage for multi-root selections.

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

* fix(clipboard): unix, select multiple items

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-04 14:11:27 +08:00
xPrimeTime
4389687d9d fix(wayland): subscribe to portal Response before making the request (#15726)
`request_remote_desktop` and its response handlers call the portal method
first and only then subscribe to the resulting Request's `Response` signal,
using the object path returned by the call. The comment above
`create_session` already describes why that is wrong:

> To avoid a race condition between the caller subscribing to the signal
> after receiving the reply for the method call and the signal getting
> emitted, a convention for Request object paths has been established that
> allows the caller to subscribe to the signal before making the method
> call.

The code then does the opposite of what the comment says. When the portal
emits `Response` before our match rule is installed, the signal is dropped
and the flow stalls: `request_remote_desktop` spins its 3-minute wait loop
and gives up, so the user sees the screen picker again (or a failure) even
when a valid restore token would have restored the session silently.

Build the request path from our unique bus name plus the `handle_token` we
pass in the call arguments, per the Request documentation, and subscribe
before calling. Applied to all five portal calls: CreateSession,
SelectSources (both the ScreenCast and post-SelectDevices paths),
SelectDevices, and Start. The `handle_token` values are unchanged; they are
now named locals so the path and the argument cannot drift apart.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:46:09 +08:00
fufesou
a84bad4639 refact(oidc): manually open the browser (#15706)
* refact(oidc): manually open the browser

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

* refact(oidc): allow copying OIDC authentication links

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

* Remove unused translation in ko.rs

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

* refact(oidc): better hint on browser didn't open

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

* refact(oidc): login handle exception

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

* refact(oidc): remove unused translations

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

* refact(oidc): login handle error

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

* refact(oidc): login in flight

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

* refact(translation): move "Continue" to the end of template.rs

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

* refact(oidc): var rename

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

* refact(oidc): remove useless "open sign-in page"

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

* Remove unecessary translation contents

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

* refact(oidc): better way to show&expand the url

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

* refact(oidc): better login ui

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

* fix(oidc): discard stale auth results after cancellation

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

* fix(oidc): handle auth status query failures safely

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

* fix(oidc): prevent concurrent login operations

- reuse the active login dialog and block duplicate password submissions
- cancel only active OIDC operations when closing the dialog
- preserve authentication state until failure cancellation succeeds

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

* fix(oidc): refine login options error feedback

Preserve typed errors to hide the network tip for
HTTP failures and clarify the login-options API contract.

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-04 12:35:04 +08:00
RustDesk
e6dd925ab0 fix(android): close outgoing sessions when the task is swiped away (#15753)
* fix(android): close outgoing sessions when the task is swiped away

Swiping RustDesk away from recents destroys the UI but does not
necessarily end the process: when MainService is running (screen share
enabled, or started at boot) the process survives, and with it the
native io_loop of any active outgoing session.

That orphaned io_loop keeps echoing TestDelay (client.rs handle_test_delay
runs entirely on the network thread, no UI involved), which keeps
refreshing last_recv_time on the controlled side. Its 30s inactivity
timeout in server/connection.rs therefore never fires, so the remote
session stays established with no UI left to close it, and the peer
cannot be reconnected to.

Close client sessions from Service.onTaskRemoved, which fires only on
explicit task removal -- not on Home or backgrounding, so ordinary
backgrounding is unaffected. The service itself keeps running, so
incoming connections and the device staying reachable are unchanged.

This complements 152c5c71b, which covered the route-pop path via
dispose(); dispose() does not run when the task is removed.

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

* fix(android): also close sessions on activity destroy

Review follow-up. onTaskRemoved only reaches MainService, but the
accessibility InputService keeps the process alive on its own: a user
with input control enabled and screen sharing off has a surviving
process after a swipe while MainService is not running, so the callback
never fires and the session still outlives its UI. onTaskRemoved cannot
cover that -- InputService is bound by the system, not started, so the
callback is not delivered there.

Close from MainActivity.onDestroy() as well, which runs while the
process is still alive regardless of which service keeps it up. Guarded
on isFinishing so a destroy for recreation (configuration change, "don't
keep activities") does not tear down a live session. Both paths are
idempotent.

Also drop the now-wrong "on task removed" wording from the Rust log,
which has two distinct callers.

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

* fix(android): release held keys before draining the session map

close_all_sessions drained SESSIONS first, then called
release_remote_keys. The release path sends through get_cur_session(),
which resolves against SESSIONS, so every generated key-up was dropped
after take_remote_keys() had already cleared TO_RELEASE: a key held as
the task is removed stays down on the controlled side until its own
timeout, with the state lost locally.

Release first, while a session is still registered. It is a no-op when
no key is held, so the previous is_empty() guard is not needed.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:18:14 +08:00
RustDesk
d752823b8c swtich_code for hbbs (#15615)
* swtich_code for hbbs to bypass ACL

* improve register_switch_grant: skip public server, log at error level

Also document why registration is fire-and-forget with no retry: the
peer connects within seconds, so a late retry would land after its
punch request was already rejected; a failed switch is recovered by
the user triggering it again, which registers a fresh grant.

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

* add timestamp

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

* fix(switch-sides): handle grant registration clock skew

  - retry registration once with the server-provided timestamp
  - require an explicit accepted response from hbbs
  - report malformed or incomplete responses

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

* fix(switch-sides): register grants with code verifiers

  - send a derived verifier instead of the raw switch code
  - use detached signatures for grant registration
  - add verifier and signed-message tests

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:08:59 +08:00
Stephan Paternotte
2f8822ec7a Update nl.rs (#15754)
* Update nl.rs

Updates plus a small improvement to the Dutch language file

* Update nl.rs

Now including fixes for coderabbit reportings

* Update nl.rs

Three more fixes re. greptile

* Update nl.rs

typo 'loskoppelenn' fixed as well
2026-08-04 10:29:31 +08:00
RustDesk
ffe20bb297 Login options error feedback (#15727)
* fix(flutter): show error and retry when fetching login options fails

The third-party login section of the login dialog was silently hidden
whenever /api/login-options could not be fetched (e.g. TLS handshake
aborted by a router/ISP scam filter, discussion #15700), leaving users
staring at a dialog with no feedback. The pure-Dart HTTP path also had
no timeout, so a black-holed connection could hang indefinitely.

- let transport errors propagate from queryOidcLoginOptions instead of
  swallowing them; a non-JSON response still means "no third-party
  login" so self-hosted servers without this API keep the old behavior
- show network_error_tip, a Retry button, and the underlying error in
  the login dialog so users and supporters can see what failed
- bound the Dart HTTP branch with a 15s timeout; the Rust branch keeps
  its own bounded per-attempt timeouts and is awaited to completion so
  a retry never races the URL-keyed ASYNC_HTTP_STATUS entry of an
  abandoned in-flight request

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

* fix(flutter): surface currentUser refresh failures that were only logged

Non-transport failures of the token auto-login (/api/currentUser) -- a
bad HTTP status, a filter's HTML block page, or an error field in the
body -- were only debugPrinted, so the address book / group tabs showed
nothing and offered no retry. Reuse the existing networkError channel
so netWorkErrorWidget shows the error with its Retry button.

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

* fix(flutter): keep retry row visible with progress while refetching login options

Review follow-ups: clicking Retry used to clear the error and hide the
row with no pending feedback, which could read as a dead click while
the Rust fallback chain runs; keep the row, disable the button, and
show the usual LinearProgressIndicator instead. Also raise the Dart
HTTP branch timeout to 30s so large web address book pulls on slow
links do not newly time out; it still bounds the previously unbounded
hang and stays above the Rust side's 12s per-attempt timeout.

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

* chore: update webpki-roots to latest Mozilla root store

0.26.9 -> 0.26.11 (now a forwarding shim over 1.x, used by tungstenite)
1.0.4 -> 1.0.9 (used by reqwest / hyper-rustls / hbb_common)

The 0.26.9 line carried its own root snapshot frozen in early 2025, so
the websocket TLS path was building against a stale bundle.

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

* ci: weekly workflow to PR webpki-roots root store updates

webpki-roots is a transitive dependency, so dependabot's cargo version
updates would not cover it. A scheduled job runs cargo update for every
webpki-roots instance in each lockfile and opens a PR when the pinned
Mozilla root snapshot is behind, keeping root store changes reviewable
instead of baking them silently into release builds.

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

* fix(flutter): hide network tip for server-reported currentUser errors

Review follow-up: when /api/currentUser fails with an error the server
itself reported (an error field in a JSON body, or an unexpected
schema), "Please check your network connection" was misleading. Track
whether the surfaced error came from a server response and skip the
network tip for those; FormatException (a non-JSON body such as a
filter's block page) keeps it, since that still indicates a network or
middlebox problem.

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

* fix(flutter): close timed-out HTTP clients

* fix(flutter): flag server-reported errors at the throw site

Review follow-up (CodeRabbit). Classifying by `e is! FormatException`
mislabeled ambiguous failures: a middlebox block page returning 200
with valid-but-wrong-shape JSON throws a TypeError from fromJson and
was shown without the check-your-network tip, though it is a network
artifact. Set networkErrorFromServer only at the one site that is
certainly server-reported (an error field in the body); every other
failure keeps the network tip plus the raw error text.

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

* ci: serialize webpki-roots update runs, null-delimit lockfile paths

Review follow-up (CodeRabbit). A manual dispatch overlapping the weekly
cron could have an older run force-push over the newer branch state;
queue runs via a concurrency group without cancel-in-progress. Also
iterate lockfiles with git ls-files -z so a path with spaces cannot be
word-split, and keep the loop failing the step on any cargo error.

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

* fix(flutter): improve login retry feedback

Use the theme primary color for the Retry button and hide stale
error messages while a retry is in progress.

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

* fix(flutter): surface login option response errors

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-04 10:19:53 +08:00
RustDesk
a5018a022b chore(ios): remove unused GoogleService-Info.plist (#15752)
Leftover from an abandoned Firebase integration. The file is not
referenced anywhere in the repository and is not listed in
Runner.xcodeproj, so it was never copied into the app bundle. No
Firebase or Google Sign-In pod is present in Podfile/Podfile.lock,
nothing calls FirebaseApp.configure(), Info.plist declares no
REVERSED_CLIENT_ID URL scheme, and on the Dart side both
Firebase.initializeApp() and firebase_analytics stay commented out.

Note the values it held were Firebase client configuration (project
identifiers and a public OAuth client id), which are public by design
and ship inside client binaries -- not secrets. This removes dead
weight, it is not a credential rotation.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:55:57 +08:00
Mr-Update
6c69faaa1c Update de.rs (#15733) 2026-08-03 15:47:26 +08:00
Daniel Marschall
b19f1ef76f Add SBOM (Software Bill of Materials) for the EU Cyber Resilience Act (EU CRA) (#15732)
* Update flutter-build.yml to generate SBOM

* SBOM Generation: Also checkout submodules

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-03 15:17:05 +08:00
fufesou
807e05ea9a refact(oidc): login with api domain (#15710)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-30 18:23:48 +08:00
rustdesk
e0254d997e fix ci 2026-07-30 16:40:56 +08:00
RustDesk
006b9737e4 fix(linux): load librustdesk.so relative to the executable (#15719)
* fix(linux): load librustdesk.so relative to the executable

The runner and the Dart FFI init loaded the core library by bare name,
relying on the runner's $ORIGIN/lib RPATH. Repackaged installs (CachyOS
repo, AUR) can lose that RPATH, making the app fail to start with
"Failed to load librustdesk.so" unless users add the lib directory to
ld.so.conf. Resolve lib/librustdesk.so next to the executable first,
then fall back to the loader search path.

https://github.com/rustdesk/rustdesk/discussions/14407

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

* fix(linux): harden bundled librustdesk.so resolution

Address review: bail out when readlink() may have truncated the
executable path, and widen the Dart try block so any failure probing
the bundled library falls back to the loader search path.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:40:27 +08:00
rustdesk
5aeb4cf945 add zstd to reqwest 2026-07-30 15:28:39 +08:00
rustdesk
c6c53f094a chore(flutter): bump desktop_multi_window to fix the Windows /WX build
The give-up log added in the white-window follow-ups declared a local
named message inside MessageHandler, shadowing its UINT message
parameter. MSVC C4457 plus /WX failed both Windows nightly jobs.
Point the lock at rustdesk_desktop_multi_window#35 which renames it.

https://github.com/rustdesk/rustdesk/actions/runs/30512756157

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:19:05 +08:00
RustDesk
e63df74715 fix(linux): make quit_cm actually quit the connection manager (#15718)
quit_gui() ends the process on Windows (std::process::exit) and macOS
(NSApp terminate), but on Linux it calls gtk_main_quit(), which has no
effect in the Flutter connection manager: flutter/linux/main.cc runs
g_application_run() (GtkApplication), so gtk_main() is never called and
the assertion inside gtk_main_quit() just fails.

quit_cm() is the only caller that relies on quit_gui() to end the
process. The main window path in ipc.rs calls std::process::exit(-1)
right after it, and the two remaining call sites are in the Sciter UI,
which is not compiled for flutter builds. So a connection manager
reaching quit_cm() on Linux kept running while no longer serving the
`_cm` ipc endpoint, which also stops the server from reusing it, so the
next connection spawns one more.

NOTE: this is a fallback, not an explanation for the stale processes of
#15698: a client merely disconnecting does not reach quit_cm(), the
Flutter side closes the window instead.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:01:05 +08:00
RustDesk
9aeb54cf33 Fix flutter white window forceredraw (#15717)
* fix(flutter/windows): heal the white window left by a resize around the first frame

If the window is resized between the creation of the Flutter surface and
the present of the first frame - which is what the PowerToys FancyZones
option "Move newly created windows to their last known zone" does - the
embedder's resize synchronization enters kResizeStarted and from then on
only presents frames that match the new size. A frame already generated
for the old size is rejected, nothing schedules a matching one, and the
window stays white until a real resize re-enters OnWindowSizeChanged,
which resets the resize target and resends the window metrics. Sciter is
unaffected: it repaints synchronously on WM_PAINT and has no such
handshake. Upstream has no fix (flutter/flutter#159630, open at P3).

Recover with a timer armed at creation and re-armed on WM_SHOWWINDOW
(covers windows created hidden and shown much later, e.g. the connection
manager): until the first frame arrives, kick the engine - first with
the cheap ForceRedraw(), which only helps when no resize is pending (it
is gated on resize_status_ == kDone), then by nudging the Flutter child
window by 1px and back, which re-enters OnWindowSizeChanged and heals
the wedge the same way minimize/restore does. Because the first-frame
callback fires on frame generation even when the present is rejected, a
resize observed before the first frame forces one final child refresh -
in practice nearly every window sees a pre-first-frame WM_SIZE, so this
acts as a cheap unconditional guarantee. Giving up after 5s is logged.

The remote session windows get the same fix in
rustdesk_desktop_multi_window.

https://github.com/rustdesk/rustdesk/issues/6756

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

* chore(flutter): bump desktop_multi_window for the white-window fix

Picks up rustdesk-org/rustdesk_desktop_multi_window#33 (340ca43), the
session-window side of the FancyZones white-window workaround. Only the
resolved-ref of this one dependency is moved; nothing else is upgraded.

https://github.com/rustdesk/rustdesk/issues/6756

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

* fix(flutter/windows): drop a dead guard and log where users can see it

Two follow-ups on the force-redraw timer.

The resized_before_first_frame_ guard never discriminated. CreateWindow()
sends a WM_SIZE before it returns, and WM_NCCREATE has already installed the
window pointer by then, so the flag was set during construction - before
OnCreate() even arms the timer - and was therefore always true when the first
frame arrived. Drop the flag and do the final child refresh unconditionally,
which is what the code already did, and say so instead of implying there is an
exceptional case.

The give-up message went to std::cerr, which lands nowhere on the machines
that hit this: main.cpp only attaches a console when the process is started
from one or runs under a debugger. Use OutputDebugString so it is actually
readable with DebugView in the field.

Also note in the comment that the "callback fires on frame generation" premise
is not load-bearing - if it only fired on a successful present, the timer would
simply keep nudging - so the redundancy is not mistaken for duplication and
removed later.

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

* chore(flutter): bump desktop_multi_window to pick up the follow-ups

Moves the pin from the #33 merge (340ca43) to current master (f8c4fce),
which adds #34: the dead resized_before_first_frame_ guard is gone and the
give-up message goes to OutputDebugString instead of a stderr nobody sees.

Keeps the sub-window fix in step with the runner fix in this branch; without
it the two would ship the same logic in two different states.

Edited by hand, not via pub upgrade - that re-resolves unrelated packages.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:15:05 +08:00
lunar-me
3442648afe docs: fix 'lowlevel' spelling to 'low-level' in libs/clipboard/README.md (#15712)
Co-authored-by: pi <pi@m2.local>
2026-07-30 10:32:39 +08:00
lunar-me
8545b5ed98 docs: fix 'gressful' misspelling to 'graceful' in libs/clipboard/README.md (#15713)
Co-authored-by: pi <pi@m2.local>
2026-07-30 10:32:23 +08:00
lunar-me
72c052cb9a docs: fix double space in CODE_OF_CONDUCT.md (#15714)
Co-authored-by: pi <pi@m2.local>
2026-07-30 10:32:07 +08:00
RustDesk
12f2de5959 chore(flutter): point window_manager at the post-revert main (#15709)
The lock still pinned 7d9a674, the commit rustdesk-org/window_manager#8
reverted. Move it to current main (cf4aef0), which carries the reworked
guard for methods called after the toplevel window is destroyed.

Edited by hand rather than via pub upgrade: upgrading re-resolved 17
packages, downgrading some and pulling flutter_test and its leak_tracker
tree in as new entries, none of which belongs in this change.

https://github.com/rustdesk/rustdesk/issues/15703

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:09:29 +08:00
rustdesk
a6708f40e7 fix https://github.com/rustdesk/rustdesk/issues/15703 2026-07-29 12:31:40 +08:00
RustDesk
85a5fefab8 fix(windows): prevent ghost and duplicate tray icons (#15689) (#15690)
* docs(agents): require minimally invasive, additive-first patches

Codify the review feedback from the tray ghost-icon fix: fixes should
add self-contained code around existing lines instead of restructuring
them, keep platform-specific logic in src/platform/ with fn-local
imports, and leave only thin one-line hooks in shared files.

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

* fix(windows): stop duplicate tray icons from piling up (#15689)

`check_process("--tray", ..)` is used to decide whether a tray process
needs to be spawned, but it can miss one that is already running: it
cannot read the command line of an elevated process from a non-elevated
one (the installer spawns the tray elevated), and wmic, used by 32-bit
builds since #11638, is gone from newer Windows 11. `connection.rs` runs
that check once per incoming connection, so every miss added another tray
icon and they kept piling up, which is the same blind spot behind #6692.

Hold a named mutex in the session namespace as the authoritative single
instance guard, so a redundant tray process exits before creating an
icon. `ERROR_ACCESS_DENIED` also counts as "already running", since it
means the mutex belongs to a tray we may not touch.

Also remove the icon before the tray menu's "Stop service" calls
uninstall_service(): on success it ends the process with
std::process::exit, which skips the destructor that would call
Shell_NotifyIcon(NIM_DELETE), so every click left a ghost icon behind.
The icon is shown again if stopping the service failed or was cancelled.

Ghost icons from the taskkill in the install/update/service flows are
left alone here.

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

* docs(windows): note that update_me's pid lookup can silently find nothing

The pids are matched by command line, which comes back empty for a 32-bit
build reading 64-bit processes (hence the `wmic` fallback of #11638, and
`wmic` is no longer installed by default since Windows 11 24H2) and for a
non-elevated process reading an elevated one. `taskkill` matches by image
name and still works, but the session lists are then empty, so the restore
guard silently restores nothing and the update leaves the user without a
tray icon and main window.

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

* docs(windows): record the confirmed cause of the duplicate tray icons

Process Explorer output in #15689 pinned it down: run_after_run_cmds()
spawns the tray in the caller's own context, so installing or toggling
the service from a RustDesk that was itself started elevated leaves a
high integrity tray behind, which a medium integrity main window cannot
inspect afterwards. Record where the detection fails exactly, so the next
reader doesn't have to rediscover that the executable path, not the
command line, is what comes back empty.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:18:57 +08:00
rustdesk
d412d19872 aligned_u8_vec 2026-07-28 13:38:36 +08:00
rustdesk
4dd8e20392 improve id whitelist login failures 2026-07-28 13:36:35 +08:00
rustdesk
dabdbf73bb improve id wildcast 2026-07-28 00:09:45 +08:00
RustDesk
d6ea170061 Id whitelist (#15586)
* id whitelist

* hbb_common

* Update flutter/lib/common/widgets/dialog.dart

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* support wss:// for web client

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

* fix: handle ID copying separately and remove whitelist logs

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

* fix en translation

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

* fix: check switch-side ID whitelist after login initialization

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

* track pending 2FA challenge state

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

* support Unicode IDs in whitelist settings

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

* refactor: unify client ID resolution

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-07-27 23:24:32 +08:00
fufesou
eefd22b205 fix(macos): prevent remote keyboard focus leaks (#15629)
* fix(macos): prevent remote keyboard focus leaks

Gate keyboard grabbing on window, tab, lifecycle, and primary focus state.
Release grabs on focus loss or minimize and avoid duplicate grab transitions.

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

* fix: macos, keyboard focus, comments known issue

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

* fix: macos, keyboard, fullscreen space switch

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

* fix: macos, keyboard, focus, relative mouse mode

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

* fix(macOS): preserve local overlay focus during input recovery

Prevent fullscreen and relative-mouse focus recovery from reclaiming
remote keyboard input while a local chat or dialog overlay owns focus.

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

* fix: macos, keyboard, comments trade-off

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-26 11:54:01 +08:00
FrederickStempfle
5882346caa fix: validate remote audio channel count (#15673) 2026-07-26 09:04:20 +08:00
FrederickStempfle
b1fad7bbed fix: validate RGBA clipboard dimensions (#15672) 2026-07-26 08:56:22 +08:00
dongrencd
57456f0b52 feat(terminal): add Ctrl and Alt toggles to mobile terminal keyboard (#15532)
* feat(terminal): add Ctrl toggle and Ctrl+X shortcut keys to mobile terminal floating keyboard

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>

* refactor(terminal): restructure keyboard layout with collapse button

- Move | from Row1 position 3 to Row1 end (aligned with collapse button)
- Remove ~ from Row2, add collapse button (∨/∧) after PgDn
- Row3: conditional render, add ~ and -, remove trailing placeholders
- Collapse state persisted via kOptionEnableShowTerminalCtrlKeys
- Row3 defaults to collapsed for compact layout

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>

* fix(terminal): restore trailing placeholders in Row3 for alignment

Row3 needs trailing placeholders to match Row1/Row2 width (348px)
so Ctrl aligns with Tab in Row2 and Esc in Row1.

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>

* fix(terminal): update mobile keyboard layout per review

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): address mobile keyboard review regressions

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): preserve ctrl-j newline mapping on mobile

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): preserve pasted input with modifiers

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): harden mobile modifier and paste input

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): harden mobile paste shortcut handling

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): preserve unicode graphemes under ctrl

* fix(terminal): avoid modifier scan for inactive locks

* fix(terminal): keep default hardware paste shortcuts

* fix(terminal): guard hardware paste with modifier locks

* fix(terminal): update mobile key button color role

---------

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>
Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>
Co-authored-by: dongrencd <dongrencd@users.noreply.github.com>
Co-authored-by: dong.ren.cd <dong.ren.cd@tcl.com>
2026-07-25 22:33:16 +08:00
21pages
cefff781d4 feat(recording): add visibility and service storage options (#15662)
* feat(recording): add visibility and service storage options

  - support hide-recording-button in Flutter and Sciter
  - allow a custom save directory for Windows service recordings
  - sanitize peer IDs used in recording filenames

  Tested:
  - with hide-recording-button=Y and allow-auto-record-outgoing=Y,
    outgoing sessions are recorded automatically while the recording button
    remains hidden and cannot be stopped from the UI; verified on Flutter
    desktop, Sciter, and Android
  - windows-service-video-save-directory takes effect when the Windows client
    runs as an installed service
  - the Windows controlling side can save recordings for direct IP:port
    connections

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

* update hbb_common

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

* fix(recording): validate configured save directories

  - trim configured recording directory paths
  - reject non-absolute paths and fall back to defaults
  - warn when a non-empty path is invalid

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

* fix(recording): validate configured save directories

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-07-25 15:21:13 +08:00
fufesou
ad9dac1001 fix(keyboard): jis, macos, muhenkan henkan (#15669)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-25 09:36:25 +08:00
CHarris
b4af82157b fix: refresh wayland uinput range on compositor layout change (#15628)
* fix: refresh wayland uinput range on compositor layout change

The uinput absolute range is computed once at session init. If the
compositor layout changes mid-session (monitor scale or position
change, or a portal virtual output appearing once capture starts),
injected coordinates are rescaled by the stale range and land offset.

Poll the live desktop bounding box from the display service loop while
subscribed (one wayland roundtrip, throttled to 1.5s, no subprocesses)
and re-apply the uinput resolution when it changes. Also read a fresh
layout when computing the initial range in check_init, since the cache
is not cleared when a session closes through the restore-token path.

This is the X component of #15601. The stale advertised origins (the Y
component) are not touched here: re-advertising DisplayInfo mid-session
trips the portal re-negotiation and can drop displays.

Signed-off-by: Cody Harris <codyharris7188@gmail.com>

* fix: bound the mouse resolution IPC wait during session init

Wrap update_mouse_resolution in the same 3s timeout the periodic
refresh uses, so a hung IPC response can't stall check_init.

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

* fix: build timeout future inside runtime, split linux lazy_static

Constructing the timeout future eagerly as the block_on argument panics
with 'there is no reactor running'; move it into the async block so it is
built inside the runtime context. Also move WAYLAND_UINPUT_RECT into its
own cfg-gated lazy_static block, an attribute on a single item inside the
shared block does not compile.

* fix: confirm uinput mouse device adopted new range before caching rect

send_refresh() now waits for the mouse service to ack that it recreated the
device with the new range instead of firing and forgetting, and
update_mouse_resolution() propagates that result. The layout poller only
caches the rect after the device actually adopts the range, so a failed
refresh errors and retries on the next check. The ack read is bounded by
IPC_REQUEST_TIMEOUT, matching the keyboard get-key-state path.

* fix: propagate refresh failures instead of caching a stale range

- input_service: error when the custom-mouse downcast fails so the poller
  retries instead of caching an unconfirmed refresh
- uinput: on device recreation failure, keep the current device and the
  IPC connection and withhold the ack so the client retries, instead of
  killing the mouse handler

* fix: remap injected wayland coords onto the live layout after a monitor moves

The range refresh corrects the uinput ABS bounds, but a single-display client
sends whole-desktop coordinates offset by the origin of the display it follows,
taken from the layout advertised at session init. When another monitor is
rescaled or moved that origin shifts, so the coordinate lands offset before it
reaches uinput and the range refresh cannot recover it.

Snapshot the per-display layout at init, poll the live layout on the existing
1.5s throttle, and when they differ remap each injected move into the followed
display's current rectangle (matched by connector name, index fallback when the
compositor reports none). No-op and lock-free while the layout is unchanged.

---------

Signed-off-by: Cody Harris <codyharris7188@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:35:49 +08:00
21pages
beaa754299 fix stale primary display selection (#15460)
* fix stale primary display selection

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

* fix stale display selection during login and switching

  - resolve the primary display from the refreshed login snapshot
  - defer display enumeration until authentication succeeds
  - read Wayland displays and primary index from the same cache snapshot
  - reject stale monitor and camera indices during display switching

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

* fix inconsistent display snapshots during login

  - return displays from the same enumeration used to select the primary
  - avoid re-reading the shared display cache after updating it
  - use the same converted snapshot during Wayland initialization

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

* avoid cloning unchanged display snapshots

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

* fix invalid display subset handling

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

* minimize code churn in switch_display_to

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-07-23 17:17:01 +08:00
bmmh1
929e989f17 feat(macos): silent auto-update with security hardening (#15550)
Co-authored-by: bmmh1 <bmmh1@users.noreply.github.com>
2026-07-23 00:22:10 +08:00
371 changed files with 46286 additions and 13495 deletions

View File

@@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: "gitsubmodule"
directory: "/"
target-branch: "master"
schedule:
interval: "daily"
commit-message:
prefix: "Git submodule"
labels:
- "dependencies"

View File

@@ -17,6 +17,109 @@
# therefore CRLF-safe. # therefore CRLF-safe.
set -euo pipefail set -euo pipefail
readonly NO_MATCHES=0
readonly SINGLE_MATCH=1
readonly THEME_MATCHES=2
has_exact_count() {
local -r expected_count="$1"
local -r pattern="$2"
local -r file="$3"
local actual_count
[[ -r "$file" ]] || return 1
actual_count="$(grep -cF "$pattern" "$file" || true)"
[[ "$actual_count" -eq "$expected_count" ]]
}
# The target background-color line must directly follow DialogThemeData in the selected range.
has_dialog_background_in_theme_range() {
local -r start_pattern="$1"
local -r end_pattern="$2"
local -r target_pattern="$3"
local -r file="$4"
awk -v start_pattern="$start_pattern" \
-v end_pattern="$end_pattern" \
-v target_pattern="$target_pattern" '
index($0, start_pattern) {
in_theme = 1
next
}
in_theme && index($0, end_pattern) {
exit
}
in_theme && index($0, "dialogTheme: DialogThemeData(") {
if (getline > 0) {
line = $0
sub(/\r$/, "", line)
sub(/^[[:space:]]+/, "", line)
matched = line == target_pattern
}
exit
}
END {
exit matched ? 0 : 1
}
' "$file"
}
validate_patch_inputs() {
if [[ ! -f flutter/lib/common.dart || ! -r flutter/lib/common.dart ]]; then
echo "Flutter 3.44 source patch input is missing or unreadable: flutter/lib/common.dart" >&2
return 1
fi
if [[ ! -f flutter/pubspec.yaml || ! -r flutter/pubspec.yaml ]]; then
echo "Flutter 3.44 source patch input is missing or unreadable: flutter/pubspec.yaml" >&2
return 1
fi
}
is_complete_patch_state() {
has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart &&
has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart &&
has_exact_count "$SINGLE_MATCH" 'backgroundColor: Colors.white,' flutter/lib/common.dart &&
has_exact_count "$SINGLE_MATCH" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart &&
has_exact_count "$SINGLE_MATCH" 'extended_text: 15.0.2' flutter/pubspec.yaml &&
has_exact_count "$SINGLE_MATCH" 'google_fonts: ^8.1.0' flutter/pubspec.yaml &&
has_exact_count "$NO_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart &&
has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart &&
has_exact_count "$NO_MATCHES" 'extended_text: 14.0.0' flutter/pubspec.yaml &&
has_exact_count "$NO_MATCHES" 'google_fonts: ^6.2.1' flutter/pubspec.yaml &&
has_dialog_background_in_theme_range 'static ThemeData lightTheme = ThemeData(' \
'static ThemeData darkTheme = ThemeData(' 'backgroundColor: Colors.white,' \
flutter/lib/common.dart &&
has_dialog_background_in_theme_range 'static ThemeData darkTheme = ThemeData(' \
'scrollbarTheme: scrollbarThemeDark,' 'backgroundColor: Color(0xFF18191E),' \
flutter/lib/common.dart
}
is_unpatched_state() {
has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart &&
has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart &&
has_exact_count "$SINGLE_MATCH" 'extended_text: 14.0.0' flutter/pubspec.yaml &&
has_exact_count "$SINGLE_MATCH" 'google_fonts: ^6.2.1' flutter/pubspec.yaml &&
has_exact_count "$NO_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart &&
has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart &&
has_exact_count "$NO_MATCHES" 'backgroundColor: Colors.white,' flutter/lib/common.dart &&
has_exact_count "$NO_MATCHES" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart &&
has_exact_count "$NO_MATCHES" 'extended_text: 15.0.2' flutter/pubspec.yaml &&
has_exact_count "$NO_MATCHES" 'google_fonts: ^8.1.0' flutter/pubspec.yaml
}
if ! validate_patch_inputs; then
exit 1
fi
if is_complete_patch_state; then
echo "Flutter 3.44 source patches already applied."
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
exit 0
fi
if ! is_unpatched_state; then
echo "Flutter 3.44 source patches are partially applied or their anchors have drifted." >&2
exit 1
fi
# ThemeData API renames (Flutter 3.27+): # ThemeData API renames (Flutter 3.27+):
sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart
sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart
@@ -28,12 +131,10 @@ sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThem
sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml
sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml
# Fail loudly if any expected string drifted, so we never silently build unpatched: # Fail loudly if any expected substitution did not produce the complete state.
grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart if ! is_complete_patch_state; then
grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart echo "Flutter 3.44 source patches did not produce the expected state." >&2
grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart exit 1
grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart fi
grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Prepares a web build on Flutter 3.44.x. Companion to
# apply_flutter_3.44_source_patches.sh (which it runs first): the web target
# additionally needs qr_code_scanner's web implementation patched for the
# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with
# the font paths the 3.44 engine requests for offline/air-gapped support
# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py).
#
# Run from the repository root with Flutter 3.44.x on PATH, then build:
# bash .github/patches/apply_flutter_3.44_web_patches.sh
# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py
#
# Idempotent. To undo the source changes locally:
# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock
set -euo pipefail
flutter --version | grep -q "Flutter 3\.44\." || {
echo "Flutter 3.44.x must be on PATH; found:" >&2
flutter --version | grep "^Flutter" >&2 || true
exit 1
}
# Shared 3.44 source/pubspec patches own their complete-state validation.
bash .github/patches/apply_flutter_3.44_source_patches.sh
# Populate the pub cache with the 3.44 dependency resolution.
(cd flutter && flutter pub get)
# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from
# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The
# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so
# mutating the shared pub cache is safe for other local builds.
QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart"
if ! grep -qF "dart:ui_web" "$QR_WEB"; then
sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB"
rm -f "$QR_WEB.bak"
fi
if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then
sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB"
rm -f "$QR_WEB.bak"
fi
# Mirror the fonts this engine version requests into flutter/web/fonts.
python3 flutter/web/fonts/sync_fonts.py
# Fail loudly if any expected state is missing:
grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB"
grep -qF "ui_web.platformViewRegistry" "$QR_WEB"
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
echo "Flutter 3.44 web patches applied."

42
.github/scripts/sign-macos-app.sh vendored Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
app_path=$1
identity=$2
entitlements=$3
sign_args=(--force --options runtime --sign "$identity")
if [[ "$identity" != "-" ]]; then
sign_args+=(--timestamp)
fi
frameworks_path="$app_path/Contents/Frameworks"
if [[ -d "$frameworks_path" ]]; then
while IFS= read -r -d '' code; do
if file -b "$code" | grep -q 'Mach-O'; then
codesign "${sign_args[@]}" "$code"
fi
done < <(find "$frameworks_path" -type f -print0)
while IFS= read -r -d '' framework; do
codesign "${sign_args[@]}" "$framework"
done < <(find "$frameworks_path" -depth -type d -name '*.framework' -print0)
fi
service_path="$app_path/Contents/MacOS/service"
if [[ -f "$service_path" ]]; then
codesign "${sign_args[@]}" "$service_path"
fi
codesign "${sign_args[@]}" --generate-entitlement-der \
--entitlements "$entitlements" "$app_path"
codesign --verify --deep --strict --verbose=2 "$app_path"
actual_entitlements=$(codesign -d --entitlements :- "$app_path" 2>/dev/null)
audio_input=$(plutil -extract 'com\.apple\.security\.device\.audio-input' raw - \
<<<"$actual_entitlements")
if [[ "$audio_input" != "true" ]]; then
echo "Missing com.apple.security.device.audio-input entitlement" >&2
exit 1
fi

View File

@@ -30,7 +30,7 @@ jobs:
target: x86_64-unknown-linux-gnu, target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04, os: ubuntu-22.04,
extra-build-args: "", extra-build-args: "",
flutter-version: "3.44.0", flutter-version: "3.44.8",
artifact-name: "bridge-artifact-flutter-3.44", artifact-name: "bridge-artifact-flutter-3.44",
} }
steps: steps:

View File

@@ -5,7 +5,7 @@ env:
# CICD_INTERMEDIATES_DIR: "_cicd-intermediates" # CICD_INTERMEDIATES_DIR: "_cicd-intermediates"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# for multiarch gcc compatibility # for multiarch gcc compatibility
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
on: on:
workflow_dispatch: workflow_dispatch:
@@ -124,7 +124,6 @@ jobs:
gcc \ gcc \
git \ git \
g++ \ g++ \
libpam0g-dev \
libasound2-dev \ libasound2-dev \
libunwind-dev \ libunwind-dev \
libgstreamer1.0-dev \ libgstreamer1.0-dev \

View File

@@ -31,20 +31,21 @@ env:
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7 # engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few # support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44"). # 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
FLUTTER_WINDOWS_ARM_VERSION: "3.44.0" FLUTTER_WINDOWS_ARM_VERSION: "3.44.9"
# for arm64 linux because official Dart SDK does not work # for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9" FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}" TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2025.08.27 # vcpkg version: 2026.07.29
# If we change the `VCPKG COMMIT_ID`, please remember: # If we change the `VCPKG COMMIT_ID`, please remember:
# 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`. # 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`.
# Or we may face build issue like # Or we may face build issue like
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174 # https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
VCPKG_CMAKE_VERSION: "4.3.0"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.9" VERSION: "1.5.0"
NDK_VERSION: "r28c" NDK_VERSION: "r28c"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -53,6 +54,34 @@ env:
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2" SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2"
jobs: jobs:
generate-sbom:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Install Syft
uses: anchore/sbom-action/download-syft@v0
- name: Generate SBOM
run: |
syft dir:. \
-o cyclonedx-json=rustdesk.sbom.json
- name: Publish Release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
if: env.UPLOAD_ARTIFACT == 'true'
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
rustdesk.sbom.json
generate-bridge: generate-bridge:
uses: ./.github/workflows/bridge.yml uses: ./.github/workflows/bridge.yml
@@ -196,7 +225,9 @@ jobs:
run: | run: |
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter))) cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
fi
- name: Patch RustDesk sources for Flutter 3.44 (arm64) - name: Patch RustDesk sources for Flutter 3.44 (arm64)
# arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly # arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly
@@ -359,6 +390,54 @@ jobs:
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
sha256sum ../../SignOutput/rustdesk-*.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 - name: Sign rustdesk self-extracted file
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2' if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
shell: bash shell: bash
@@ -567,7 +646,9 @@ jobs:
- name: Patch flutter - name: Patch flutter
run: | run: |
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
fi
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
@@ -746,7 +827,9 @@ jobs:
- name: Patch flutter - name: Patch flutter
run: | run: |
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
fi
- name: Workaround for flutter issue - name: Workaround for flutter issue
shell: bash shell: bash
@@ -843,7 +926,11 @@ jobs:
security unlock-keychain -p ${{ secrets.MACOS_P12_PASSWORD }} rustdesk.keychain security unlock-keychain -p ${{ secrets.MACOS_P12_PASSWORD }} rustdesk.keychain
# start sign the rustdesk.app and dmg # start sign the rustdesk.app and dmg
rm -rf *.dmg || true rm -rf *.dmg || true
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict ./flutter/build/macos/Build/Products/Release/RustDesk.app -vvv # the identity secret carries its own shell quoting, so expand it inline like the dmg codesign below
bash ./.github/scripts/sign-macos-app.sh \
./flutter/build/macos/Build/Products/Release/RustDesk.app \
${{ secrets.MACOS_CODESIGN_IDENTITY }} \
./flutter/macos/Runner/Release.entitlements
create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv
# notarize the rustdesk-${{ env.VERSION }}.dmg # notarize the rustdesk-${{ env.VERSION }}.dmg
@@ -891,15 +978,33 @@ jobs:
name: rustdesk-unsigned-windows-x86_64 name: rustdesk-unsigned-windows-x86_64
path: ./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 - name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: rustdesk-unsigned-windows-x86 name: rustdesk-unsigned-windows-x86
path: ./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 - name: Combine unsigned app
run: | 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 - name: Publish unsigned app
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
@@ -975,7 +1080,6 @@ jobs:
libgstreamer1.0-dev \ libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \ libgtk-3-dev \
libpam0g-dev \
libpulse-dev \ libpulse-dev \
libva-dev \ libva-dev \
libxcb-randr0-dev \ libxcb-randr0-dev \
@@ -1005,7 +1109,9 @@ jobs:
- name: Patch flutter - name: Patch flutter
run: | run: |
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
fi
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1 - uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
id: setup-ndk id: setup-ndk
@@ -1247,7 +1353,6 @@ jobs:
libgstreamer1.0-dev \ libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \ libgtk-3-dev \
libpam0g-dev \
libpulse-dev \ libpulse-dev \
libva-dev \ libva-dev \
libxcb-randr0-dev \ libxcb-randr0-dev \
@@ -1277,7 +1382,9 @@ jobs:
- name: Patch flutter - name: Patch flutter
run: | run: |
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
fi
- name: Restore bridge files - name: Restore bridge files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -1434,7 +1541,6 @@ jobs:
submodules: recursive submodules: recursive
- name: Set Swap Space - name: Set Swap Space
if: ${{ matrix.job.arch == 'x86_64' }}
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with: with:
swap-size-gb: 12 swap-size-gb: 12
@@ -1469,6 +1575,15 @@ jobs:
name: bridge-artifact name: bridge-artifact
path: ./ path: ./
# vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31.
- name: Install CMake for vcpkg on Linux ARM64
if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true'
run: |
python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}"
user_base="$(python3 -m site --user-base)"
"${user_base}/bin/cmake" --version
echo "${user_base}/bin" >> "${GITHUB_PATH}"
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
@@ -1536,7 +1651,6 @@ jobs:
libgstreamer1.0-dev \ libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \ libgtk-3-dev \
libpam0g-dev \
libpulse-dev \ libpulse-dev \
libva-dev \ libva-dev \
libxcb-randr0-dev \ libxcb-randr0-dev \
@@ -1721,6 +1835,275 @@ jobs:
files: | files: |
res/rustdesk-${{ env.VERSION }}*.zst res/rustdesk-${{ env.VERSION }}*.zst
# Same build as build-rustdesk-linux x86_64 -- same vcpkg/ffmpeg, same ubuntu18.04 container, same
# rust and flutter -- only with the drm feature on, so it ships as the separate
# rustdesk-unattended-wayland deb. libdrmtap is built on the runner because bionic's meson is too
# old for it. A separate job rather than a matrix entry of build-rustdesk-linux: appimage and
# flatpak need that job, and a failure here must not skip them.
build-rustdesk-linux-drm:
needs: [generate-bridge]
name: build rustdesk linux drm x86_64
runs-on: ubuntu-22.04
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Maximize build space
run: |
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/lib/android
sudo rm -rf /usr/share/dotnet
sudo apt-get update -y
sudo apt-get install -y nasm
sudo apt-get install -y qemu-user-static
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Set Swap Space
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with:
swap-size-gb: 12
- name: Free Space
run: |
df -h
free -m
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: x86_64-unknown-linux-gnu
components: "rustfmt"
- name: Save Rust toolchain version
run: |
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
- name: Disable rust bridge build
run: |
# only build cdylib
sed -i "s/\[\"cdylib\", \"staticlib\", \"rlib\"\]/\[\"cdylib\"\]/g" Cargo.toml
- name: Restore bridge files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
- name: Install vcpkg dependencies
run: |
sudo apt install -y libva-dev && apt show libva-dev
if ! $VCPKG_ROOT/vcpkg \
install \
--triplet x64-linux \
--x-install-root="$VCPKG_ROOT/installed"; then
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
echo "$_1:"
echo "======"
cat "$_1"
echo "======"
echo ""
done
exit 1
fi
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true
shell: bash
# The container's meson is too old to build libdrmtap, so build it here from the pin in
# build.py and hand the .so to the container below via DRMTAP_PREBUILT_DIR.
- name: Build libdrmtap
run: |
sudo apt-get install -y meson ninja-build pkg-config \
libdrm-dev libegl1-mesa-dev libgles2-mesa-dev
python3 - <<'PY'
import importlib.util, sys
spec = importlib.util.spec_from_file_location("b", "build.py")
b = importlib.util.module_from_spec(spec)
sys.argv = ["build.py"]
spec.loader.exec_module(b)
print(f"::notice::built {b.build_libdrmtap_so()}")
PY
shell: bash
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk
id: vcpkg
with:
arch: x86_64
distro: ubuntu18.04
githubToken: ${{ github.token }}
setup: |
ls -l "${PWD}"
ls -l /opt/artifacts/vcpkg/installed
dockerRunArgs: |
--volume "${PWD}:/workspace"
--volume "/opt/artifacts:/opt/artifacts"
shell: /bin/bash
install: |
apt-get update -y
echo -e "installing deps"
apt-get install -y \
build-essential \
clang \
cmake \
curl \
gcc \
git \
g++ \
libayatana-appindicator3-dev \
libasound2-dev \
libclang-10-dev \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
libpulse-dev \
libva-dev \
libxcb-randr0-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxdo-dev \
libxfixes-dev \
llvm-10-dev \
nasm \
ninja-build \
pkg-config \
tree \
python3 \
rpm \
unzip \
wget \
xz-utils \
libssl-dev
# we have libopus compiled by us.
apt-get remove -y libopus-dev || true
# output devs
ls -l ./
tree -L 3 /opt/artifacts/vcpkg/installed
run: |
# disable git safe.directory
git config --global --add safe.directory "*"
# rust
pushd /opt
# do not use rustup, because memory overflow in qemu
wget -O rust.tar.gz https://static.rust-lang.org/dist/rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu.tar.gz
tar -zxvf rust.tar.gz > /dev/null && rm rust.tar.gz
cd rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu && ./install.sh
rm -rf rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu
# edit config
mkdir -p ~/.cargo/
echo """
[source.crates-io]
registry = 'https://github.com/rust-lang/crates.io-index'
""" > ~/.cargo/config
cat ~/.cargo/config
# start build
pushd /workspace
export VCPKG_ROOT=/opt/artifacts/vcpkg
# use the .so built on the runner; build.py checks it is the pinned checkout
export DRMTAP_PREBUILT_DIR=/workspace/third_party/libdrmtap/build-pkg
# ask build.py for the features so this line and the packaging line cannot drift
FEATURES=$(python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --print-features)
# an empty or error-shaped value would silently build a stock binary
for want in drm drm-wake; do
case ",$FEATURES," in
*",$want,"*) ;;
*) echo "::error::build.py returned no '$want' feature: $FEATURES"; exit 1 ;;
esac
done
cargo build --locked --lib --features "$FEATURES" --release
rm -rf target/release/deps target/release/build
rm -rf ~/.cargo
# Setup Flutter
# disable git safe.directory
git config --global --add safe.directory "*"
export PATH=/opt/flutter/bin:$PATH
pushd /opt
wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
tar xf flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
flutter doctor -v
if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then
pushd /opt/flutter
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
popd
fi
# build flutter
pushd /workspace
export CARGO_INCREMENTAL=0
export DEB_ARCH=amd64
python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --skip-cargo
for name in rustdesk*??.deb; do
mv "$name" "${name%%.deb}-x86_64.deb"
done
# build.py can exit 0 on some inner failures, so check the artifact rather than the status.
# The package name is the informed consent for consent-free capture, so a stock binary must
# never ship under it: assert the bundled library AND the dlopen path in the binary.
- name: Check the deb is a drm build
run: |
set -euo pipefail
# Resolve by glob, not from env.VERSION: build.py names the deb from Cargo.toml, so a
# hardcoded name fails with a bare exit 1 the first time those two drift.
shopt -s nullglob
debs=(rustdesk-unattended-wayland-*-x86_64.deb)
if [ "${#debs[@]}" -ne 1 ]; then
echo "::error::expected one rustdesk-unattended-wayland-*-x86_64.deb, found ${#debs[@]}: ${debs[*]-none}"
exit 1
fi
deb="${debs[0]}"
echo "DRM_DEB=$deb" >> "$GITHUB_ENV"
contents="$(dpkg -c "$deb")"
if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then
echo "::error::$deb has no versioned libdrmtap.so.0.x.y"
exit 1
fi
if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then
echo "::error::$deb has no libdrmtap.so.0 soname symlink"
exit 1
fi
rm -rf /tmp/deb && dpkg-deb -R "$deb" /tmp/deb
if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/deb/usr/share/rustdesk/lib/librustdesk.so; then
echo "::error::$deb was not built with the drm feature"
exit 1
fi
shell: bash
- name: Publish debian package
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
${{ env.DRM_DEB }}
# No UPLOAD_ARTIFACT gate: on a PR this is the only way to get at the deb that was just built.
# always(), because a deb that failed the check above is the one most worth downloading.
- name: Upload deb
if: always() && env.DRM_DEB != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ env.DRM_DEB }}
path: ${{ env.DRM_DEB }}
build-rustdesk-linux-sciter: build-rustdesk-linux-sciter:
if: ${{ inputs.upload-artifact }} if: ${{ inputs.upload-artifact }}
runs-on: ${{ matrix.job.on }} runs-on: ${{ matrix.job.on }}
@@ -1771,6 +2154,12 @@ jobs:
echo "Modified vcpkg.json for armv7 build:" echo "Modified vcpkg.json for armv7 build:"
grep -A 2 -B 2 '"baseline"' vcpkg.json grep -A 2 -B 2 '"baseline"' vcpkg.json
- name: Set Swap Space
if: matrix.job.arch == 'armv7'
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with:
swap-size-gb: 12
- name: Free Space - name: Free Space
run: | run: |
df -h df -h
@@ -1818,7 +2207,6 @@ jobs:
libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \ libgtk-3-dev \
liblzma-dev \ liblzma-dev \
libpam0g-dev \
libpulse-dev \ libpulse-dev \
libva-dev \ libva-dev \
libxcb-randr0-dev \ libxcb-randr0-dev \
@@ -1895,7 +2283,7 @@ jobs:
mkdir -p ~/.cargo/ mkdir -p ~/.cargo/
echo """ echo """
[source.crates-io] [source.crates-io]
registry = 'https://github.com/rust-lang/crates.io-index' registry = 'sparse+https://index.crates.io/'
""" > ~/.cargo/config """ > ~/.cargo/config
cat ~/.cargo/config cat ~/.cargo/config
# install dependencies from vcpkg # install dependencies from vcpkg
@@ -1916,6 +2304,16 @@ jobs:
# build rustdesk # build rustdesk
python3 ./res/inline-sciter.py python3 ./res/inline-sciter.py
export CARGO_INCREMENTAL=0 export CARGO_INCREMENTAL=0
# armv7 is the only 32-bit target in this job that links the whole binary, and the
# release profile uses fat LTO with codegen-units=1. LLVM then merges every module
# into a single unit and runs past the ~3GB address space a 32-bit process gets,
# aborting rustc with "Rust cannot catch foreign exceptions" (a C++ bad_alloc from
# LLVM unwinding into rustc's Rust frames). Thin LTO keeps peak memory bounded and
# still allows cross-crate inlining; 64-bit targets keep fat LTO untouched.
if [ "${{ matrix.job.arch }}" = "armv7" ]; then
export CARGO_PROFILE_RELEASE_LTO=thin
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
fi
cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1 cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
# make debian package # make debian package
mkdir -p ./Release mkdir -p ./Release
@@ -2115,7 +2513,18 @@ jobs:
shell: bash shell: bash
run: | run: |
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
fi
- name: Patch sources for Flutter 3.44 web
# No-op while the web stays on Flutter 3.24.5; makes this job work as-is
# once FLUTTER_VERSION moves to 3.44.x (qr_code_scanner + fonts, see script).
shell: bash
run: |
if [[ "${{ env.FLUTTER_VERSION }}" == 3.44.* ]]; then
bash .github/patches/apply_flutter_3.44_web_patches.sh
fi
# https://rustdesk.com/docs/en/dev/build/web/ # https://rustdesk.com/docs/en/dev/build/web/
- name: Build web - name: Build web

View File

@@ -16,8 +16,8 @@ env:
FLUTTER_ELINUX_VERSION: "3.16.9" FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "nightly" TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
VERSION: "1.4.9" VERSION: "1.5.0"
NDK_VERSION: "r26d" NDK_VERSION: "r26d"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -271,7 +271,6 @@ jobs:
libgstreamer1.0-dev \ libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \ libgtk-3-dev \
libpam0g-dev \
libpulse-dev \ libpulse-dev \
libva-dev \ libva-dev \
libvdpau-dev \ libvdpau-dev \
@@ -284,7 +283,7 @@ jobs:
nasm \ nasm \
yasm \ yasm \
ninja-build \ ninja-build \
openjdk-11-jdk-headless \ openjdk-17-jdk-headless \
pkg-config \ pkg-config \
tree \ tree \
wget wget
@@ -366,9 +365,9 @@ jobs:
- name: Build rustdesk - name: Build rustdesk
shell: bash shell: bash
env: env:
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64 JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
run: | run: |
export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
# temporary use debug sign config # temporary use debug sign config
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
case ${{ matrix.job.target }} in case ${{ matrix.job.target }} in

View File

@@ -0,0 +1,75 @@
name: Update webpki-roots
# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots
# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at
# whatever Cargo.lock pins, so old builds miss newly added CAs and keep
# removed (distrusted) ones. Changes go through a PR on purpose: added or
# removed roots should be reviewed, not silently baked into releases.
#
# Note: PRs created with the default GITHUB_TOKEN do not trigger other
# workflows (GitHub limitation). Close and reopen the PR, or push to its
# branch, to run CI on it.
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
# A manual dispatch overlapping the weekly run would race it force-pushing
# the same branch; queue instead of overlapping, and never cancel a run
# that may have already pushed.
concurrency:
group: update-webpki-roots
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
env:
BRANCH: auto-update-webpki-roots
steps:
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# The root workspace lists libs/hbb_common as a member; without the
# submodule its manifest is missing and cargo cannot load the workspace.
submodules: recursive
- name: Update webpki-roots in all lockfiles
id: update
run: |
set -e
git ls-files -z '*Cargo.lock' | while IFS= read -r -d '' lock; do
dir=$(dirname "$lock")
for v in $(sed -n '/name = "webpki-roots"/{n;s/.*version = "\(.*\)"/\1/p;}' "$lock" | sort -u); do
echo "updating webpki-roots@$v in $dir"
(cd "$dir" && cargo update -p "webpki-roots@$v")
done
done
if git diff --quiet -- '*Cargo.lock'; then
echo "changed=0" >> "$GITHUB_OUTPUT"
else
echo "changed=1" >> "$GITHUB_OUTPUT"
git --no-pager diff -- '*Cargo.lock'
fi
- name: Create pull request
if: steps.update.outputs.changed == '1'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -e
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add -- '*Cargo.lock'
git commit -m "chore: update webpki-roots to latest Mozilla root store"
git push -f origin "$BRANCH"
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
gh pr create \
--title "chore: update webpki-roots to latest Mozilla root store" \
--body "Automated weekly refresh of the compiled-in TLS root certificates (webpki-roots). Please review the added/removed roots. CI does not run automatically on PRs created by GITHUB_TOKEN; close and reopen this PR to trigger it."
fi

4
.gitignore vendored
View File

@@ -55,4 +55,6 @@ examples/**/target/
vcpkg_installed vcpkg_installed
flutter/lib/generated_plugin_registrant.dart flutter/lib/generated_plugin_registrant.dart
libsciter.dylib libsciter.dylib
flutter/web/ flutter/web/
# libdrmtap is cloned at build time by build.py (not a submodule)
/third_party/libdrmtap/

View File

@@ -8,18 +8,24 @@
* `src/platform/` platform-specific code * `src/platform/` platform-specific code
* `src/ui/` legacy Sciter UI (deprecated) * `src/ui/` legacy Sciter UI (deprecated)
* `flutter/` current UI * `flutter/` current UI
* `libs/hbb_common/` config / proto / shared utils * `libs/hbb_common/` shared with the server: rendezvous proto, sockets, `Config` core
* `libs/base/` (crate `base`) client-only: option keys, message proto, file transfer, platform code
* `libs/scrap/` screen capture * `libs/scrap/` screen capture
* `libs/enigo/` input control * `libs/enigo/` input control
* `libs/clipboard/` clipboard * `libs/clipboard/` clipboard
* `libs/hbb_common/src/config.rs` all options * `libs/base/src/config/keys.rs` the single import path for all options
### Key Components ### Key Components
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server - **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/` - **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
- **Input Handling**: Cross-platform input simulation in `libs/enigo/` - **Input Handling**: Cross-platform input simulation in `libs/enigo/`
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/` - **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/` - **File Transfer**: Secure file transfer implementation in `libs/base/src/fs.rs`
`hbb_common` is a git submodule shared with the server, so changing it costs a
round-trip. Put client-only code in `libs/base` instead; it is a normal
workspace member. `base::config::keys` re-exports the handful of keys
`hbb_common` still reads, so callers get the whole set from that one path.
### UI Architecture ### UI Architecture
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/` - **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
@@ -61,6 +67,73 @@
* Do not make formatting-only changes. * Do not make formatting-only changes.
* Keep naming/style consistent with nearby code. * Keep naming/style consistent with nearby code.
### Imports
* One `use` per crate. Everything a file takes from the same crate goes in a
single braced block, not one statement per item:
```rust
// no
use base::fs;
use base::message_proto::*;
// yes
use base::{fs, message_proto::*};
```
* The only reason to split is a `#[cfg(...)]` that does not apply to the whole
block -- an attribute binds to one item, so a differently-gated import has to
stand on its own. A `pub use` re-export likewise cannot join a plain `use`.
```rust
#[cfg(not(feature = "flutter"))]
use base::fs;
use base::message_proto::*;
```
* When splitting an existing `use` because some of its items moved to another
crate, fold each side into that crate's existing block rather than leaving a
second statement behind.
### Comments
* Avoid comments unless they explain a non-obvious reason, constraint, or workaround.
* Never restate what the code does; prefer clearer code instead.
* If the code is self-explanatory, add no comment.
### Be minimally invasive
* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none.
* Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding).
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
### Scope check before touching shared code
* Before changing a shared trait, a shared struct, or the signature of a widely used function, check whether the bug or feature is specific to one path. If it is, keep the change inside that path unless that is impossible, and say in the PR why it was.
* If an unrelated caller needs `Default::default()`, `None`, or another placeholder solely to satisfy a signature you changed, the diff is too broad: stop and redesign.
* The expected shape of a fix is a new function in the feature's own module, plus at most a new field or a thin hook in the shared code it needs. Feature-specific state belongs beside the feature's existing state, not in a new abstraction every caller has to learn.
### Mandatory regression-surface check
Before considering any implementation complete, perform a minimization pass over the final diff.
* Inspect every modified existing file and every modified existing code path. Each must be strictly necessary for the requested change. Revert changes that are merely cleanup, refactoring, consistency improvements, or fixes for pre-existing issues.
* For new features, preserve the existing implementation path when the feature is disabled or unsupported whenever practical. `feature off` should run the old code, not a rewritten equivalent.
* Do not route existing behavior through a new abstraction merely to share code with the new feature. Prefer a parallel new function or a small amount of duplication over changing a proven existing path.
* Keep new implementation logic in new or feature-specific modules. Changes to shared/core files should normally be thin hooks, capability checks, or protocol plumbing.
* Do not fix unrelated pre-existing bugs in the same PR. Put them in a separate change unless they directly block correctness or security of the requested work.
* For submodule bumps, inspect the exact commit range and ensure unrelated changes are not being pulled into the parent PR.
* Before finalizing, explicitly report the regression surface: list the existing files and existing runtime paths whose behavior changed, and explain why each change is unavoidable.
* During review, treat an unnecessarily modified legacy path as a review finding even if tests pass and the rewritten behavior appears equivalent.
## Reviewing a PR
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.
* List pre-existing problems in a separate section at the end, or leave out the ones that are not fatal. Never mix them into the findings the author has to fix.
* Before re-reviewing, read the author's reply comments. Do not re-raise items they declined on scope grounds.
* State a finding's consequence exactly: distinguish "the value is lost" from "the shortcut is inert but the value still saves".
## Localization (`src/lang/*.rs`) ## Localization (`src/lang/*.rs`)
Each file is a `HashMap<key, translation>`. Layout: Each file is a `HashMap<key, translation>`. Layout:
@@ -68,6 +141,7 @@ Each file is a `HashMap<key, translation>`. Layout:
* `template.rs` is the master list of every key. **Never edit it** as part of translation work. * `template.rs` is the master list of every key. **Never edit it** as part of translation work.
* `en.rs` holds only the keys whose English display text differs from the key itself. * `en.rs` holds only the keys whose English display text differs from the key itself.
* Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`. * Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`.
* `it.rs` is maintained by hand by its translator. Never fill or change its entries; when adding new keys, append them to it with `""` and leave the translation to the maintainer.
### Finding the English source for a key ### Finding the English source for a key
@@ -84,3 +158,9 @@ Then translate that source into the file's target language (infer the language f
* Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source. * Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source.
* Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc. * Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc.
* Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`. * Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`.
### Adding new keys (feature work)
* New English-text keys use sentence case, not Title Case: `Use ID whitelisting`, **not** `Use ID Whitelisting`. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g. `Use IP Whitelisting`) stay as-is — do not rename them.
* Since the key itself is the English display text, a sentence-case key usually needs **no** `en.rs` entry; add one only when the display text must differ from the key (e.g. `*_tip` keys).
* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure; always `""` for `it.rs`), at the end of the list.

View File

@@ -1 +1 @@
AGENTS.md @AGENTS.md

536
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk" name = "rustdesk"
version = "1.4.9" version = "1.5.0"
authors = ["rustdesk <info@rustdesk.com>"] authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021" edition = "2021"
build= "build.rs" build= "build.rs"
@@ -22,7 +22,7 @@ path = "src/service.rs"
[features] [features]
inline = [] inline = []
use_samplerate = ["samplerate"] use_samplerate = ["samplerate", "libsamplerate-sys"]
use_rubato = ["rubato"] use_rubato = ["rubato"]
use_dasp = ["dasp"] use_dasp = ["dasp"]
flutter = ["flutter_rust_bridge"] flutter = ["flutter_rust_bridge"]
@@ -30,7 +30,13 @@ default = ["use_dasp"]
hwcodec = ["scrap/hwcodec"] hwcodec = ["scrap/hwcodec"]
vram = ["scrap/vram"] vram = ["scrap/vram"]
mediacodec = ["scrap/mediacodec"] mediacodec = ["scrap/mediacodec"]
plugin_framework = [] drm = ["scrap/drm"]
# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend
# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the
# root service so a compositor that idle-disabled its outputs re-enables them. That is a different
# kind of operation and deserves a switch that can remove it from the binary entirely, without
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
drm-wake = ["drm"]
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
unix-file-copy-paste = [ unix-file-copy-paste = [
"dep:x11-clipboard", "dep:x11-clipboard",
@@ -46,7 +52,8 @@ screencapturekit = ["cpal/screencapturekit"]
[dependencies] [dependencies]
async-trait = "0.1" async-trait = "0.1"
scrap = { path = "libs/scrap", features = ["wayland"] } scrap = { path = "libs/scrap", features = ["wayland"] }
hbb_common = { path = "libs/hbb_common" } hbb_common = { path = "libs/hbb_common", features = ["webrtc"] }
base = { path = "libs/base" }
serde_derive = "1.0" serde_derive = "1.0"
serde = "1.0" serde = "1.0"
serde_json = "1.0" serde_json = "1.0"
@@ -60,6 +67,7 @@ magnum-opus = { git = "https://github.com/rustdesk-org/magnum-opus" }
dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpolate"], optional = true } dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpolate"], optional = true }
rubato = { version = "0.12", optional = true } rubato = { version = "0.12", optional = true }
samplerate = { version = "0.2", optional = true } samplerate = { version = "0.2", optional = true }
libsamplerate-sys = { version = "0.1.12", optional = true }
uuid = { version = "1.3", features = ["v4"] } uuid = { version = "1.3", features = ["v4"] }
num_cpus = "1.15" num_cpus = "1.15"
bytes = { version = "1.4", features = ["serde"] } bytes = { version = "1.4", features = ["serde"] }
@@ -74,12 +82,11 @@ hex = "0.4"
chrono = "0.4" chrono = "0.4"
cidr-utils = "0.5" cidr-utils = "0.5"
fon = "0.6" fon = "0.6"
zip = "0.6"
shutdown_hooks = "0.1" shutdown_hooks = "0.1"
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] } totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
stunclient = "0.4" 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"], default-features=false } 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] [target.'cfg(not(target_os = "linux"))'.dependencies]
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux # https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
@@ -124,14 +131,18 @@ windows = { version = "0.61", features = [
"Win32_Security_Authorization", "Win32_Security_Authorization",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
"Win32_System", "Win32_System",
"Win32_System_Com",
"Win32_System_Diagnostics", "Win32_System_Diagnostics",
"Win32_System_Diagnostics_ToolHelp", "Win32_System_Diagnostics_ToolHelp",
"Win32_System_Environment", "Win32_System_Environment",
"Win32_System_IO", "Win32_System_IO",
"Win32_System_Memory", "Win32_System_Memory",
"Win32_System_Pipes", "Win32_System_Pipes",
"Win32_System_Registry",
"Win32_System_SystemInformation",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_UI_Shell", "Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] } ] }
winreg = "0.11" winreg = "0.11"
windows-service = "0.6" windows-service = "0.6"
@@ -140,7 +151,6 @@ remote_printer = { path = "libs/remote_printer" }
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" } impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
shared_memory = "0.12" shared_memory = "0.12"
tauri-winrt-notification = "0.1" tauri-winrt-notification = "0.1"
runas = "1.2"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2" objc = "0.2"
@@ -181,7 +191,6 @@ async-process = "1.7"
evdev = { git="https://github.com/rustdesk-org/evdev" } evdev = { git="https://github.com/rustdesk-org/evdev" }
dbus = "0.9" dbus = "0.9"
dbus-crossroads = "0.5" dbus-crossroads = "0.5"
pam = { git="https://github.com/rustdesk-org/pam" }
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
percent-encoding = {version = "2.3", optional = true} percent-encoding = {version = "2.3", optional = true}
@@ -201,13 +210,29 @@ jni = "0.21"
android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" } android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
[workspace] [workspace]
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"] members = ["libs/scrap", "libs/hbb_common", "libs/base", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
exclude = ["vdi/host", "examples/custom_plugin"] exclude = ["vdi/host"]
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo # Patch libxdo-sys to use a stub implementation that doesn't require libxdo
# This allows building and running on systems without libxdo installed (e.g., Wayland-only) # This allows building and running on systems without libxdo installed (e.g., Wayland-only)
[patch.crates-io] [patch.crates-io]
libxdo-sys = { path = "libs/libxdo-sys-stub" } 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.
# Sending that way, a reordering window keeps a chunk that is merely late from being resent on a
# path that jitters, every DATA chunk asks for its SACK at once so a lost tail is back within an
# RTT at KCP's RTO floors, and bundles of small chunks stay within the MTU. A T3-rtx resends
# everything outstanding when it packs into four packets and otherwise probes with one and lets
# the SACK settle the rest (F-RTO), timed from the latest send, so a stall no longer resends the
# whole backlog behind itself while a short lost tail still comes back at once.
# 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 = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
[package.metadata.winres] [package.metadata.winres]
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved." LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
@@ -227,6 +252,7 @@ os-version = "0.2"
[dev-dependencies] [dev-dependencies]
hound = "3.5" hound = "3.5"
docopt = "1.1" docopt = "1.1"
tokio = { version = "1.44", features = ["test-util"] }
[package.metadata.bundle] [package.metadata.bundle]
name = "RustDesk" name = "RustDesk"

View File

@@ -19,7 +19,6 @@ RUN apt update -y && \
libxcb-shape0-dev \ libxcb-shape0-dev \
libxcb-xfixes0-dev \ libxcb-xfixes0-dev \
libasound2-dev \ libasound2-dev \
libpam0g-dev \
libpulse-dev \ libpulse-dev \
make \ make \
wget \ wget \

View File

@@ -3,7 +3,7 @@
<a href="#raw-steps-to-build">Build</a> • <a href="#raw-steps-to-build">Build</a> •
<a href="#how-to-build-with-docker">Docker</a> • <a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> • <a href="#file-structure">Structure</a> •
<a href="#snapshot">Snapshot</a><br> <a href="#screenshots">Screenshots</a><br>
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br> [<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b> <b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
</p> </p>
@@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB
## Dependencies ## Dependencies
Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version. Desktop versions use Flutter or Sciter (deprecated) for GUI. This tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building the Flutter version.
Please download Sciter dynamic library yourself. Please download Sciter dynamic library yourself.
@@ -66,19 +66,19 @@ Please download Sciter dynamic library yourself.
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -158,7 +158,8 @@ Please ensure that you run these commands from the root of the RustDesk reposito
## File Structure ## File Structure
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, protobuf, fs functions for file transfer, and some other utility functions - **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, and some other utility functions shared with the server
- **[libs/base](https://github.com/rustdesk/rustdesk/tree/master/libs/base)**: protobuf, fs functions for file transfer, keyboard and platform code used only by this app
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: screen capture - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: screen capture
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platform specific keyboard/mouse control - **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platform specific keyboard/mouse control
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: file copy and paste implementation for Windows, Linux, macOS. - **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: file copy and paste implementation for Windows, Linux, macOS.
@@ -168,7 +169,6 @@ Please ensure that you run these commands from the root of the RustDesk reposito
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client
## Screenshots ## Screenshots

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.4.9 version: 1.5.0
exec: usr/share/rustdesk/rustdesk exec: usr/share/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:
@@ -58,7 +58,6 @@ AppDir:
- libpulse0 - libpulse0
- packagekit-gtk3-module - packagekit-gtk3-module
- libcanberra-gtk3-module - libcanberra-gtk3-module
- libpam0g
- libdrm2 - libdrm2
exclude: exclude:
- humanity-icon-theme - humanity-icon-theme
@@ -77,6 +76,13 @@ AppDir:
env: env:
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules
GDK_BACKEND: x11 GDK_BACKEND: x11
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
# resolves against the CWD, and that must not outrank the defaults below.
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64 APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.4.9 version: 1.5.0
exec: usr/share/rustdesk/rustdesk exec: usr/share/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:
@@ -61,7 +61,6 @@ AppDir:
- libpulse0 - libpulse0
- packagekit-gtk3-module - packagekit-gtk3-module
- libcanberra-gtk3-module - libcanberra-gtk3-module
- libpam0g
- libdrm2 - libdrm2
exclude: exclude:
- humanity-icon-theme - humanity-icon-theme
@@ -80,6 +79,13 @@ AppDir:
env: env:
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules
GDK_BACKEND: x11 GDK_BACKEND: x11
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
# resolves against the CWD, and that must not outrank the defaults below.
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64 APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0

530
build.py
View File

@@ -1,16 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import glob
import contextlib
import pathlib import pathlib
import platform import platform
import zipfile import zipfile
import urllib.request import urllib.request
import shutil import shutil
import hashlib import hashlib
import re
import subprocess
import argparse import argparse
import sys import sys
from pathlib import Path from pathlib import Path
# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__
# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04
# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code.
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
windows = platform.platform().startswith('Windows') windows = platform.platform().startswith('Windows')
osx = platform.platform().startswith( osx = platform.platform().startswith(
'Darwin') or platform.platform().startswith("macOS") 'Darwin') or platform.platform().startswith("macOS")
@@ -130,6 +139,19 @@ def make_parser():
action='store_true', action='store_true',
help='Build with unix file copy paste feature' help='Build with unix file copy paste feature'
) )
parser.add_argument(
'--drm',
action='store_true',
help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, '
'dlopen-ed in-process by the root service). Off by default.'
)
parser.add_argument(
'--print-features',
action='store_true',
help='Print the cargo feature list these flags select, and exit without building. For a '
'caller that runs its own cargo line and then packages with --skip-cargo: it can ask '
'for the list rather than repeat it, so the two cannot drift.'
)
parser.add_argument( parser.add_argument(
'--skip-cargo', '--skip-cargo',
action='store_true', action='store_true',
@@ -272,6 +294,24 @@ def external_resources(flutter, args, res_dir):
shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}') shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}')
def linux_packaging_branch():
"""Which packaging path `main()` will take on THIS host.
MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can
refuse a branch that is not drm-aware instead of silently producing a stock-named package with
the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which
is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged
binary really is a drm build.
"""
if os.path.isfile('/usr/bin/pacman'):
return 'pacman'
if os.path.isfile('/usr/bin/yum'):
return 'yum'
if os.path.isfile('/usr/bin/zypper'):
return 'zypper'
return 'deb'
def get_features(args): def get_features(args):
features = ['inline'] if not args.flutter else [] features = ['inline'] if not args.flutter else []
if args.hwcodec: if args.hwcodec:
@@ -282,6 +322,30 @@ def get_features(args):
features.append('flutter') features.append('flutter')
if args.unix_file_copy_paste: if args.unix_file_copy_paste:
features.append('unix-file-copy-paste') features.append('unix-file-copy-paste')
if args.drm:
# Say so rather than quietly handing back a stock build: the backend is Linux-only, so on
# any other host the flag cannot be honoured and the resulting binary would look like a
# DRM build without being one.
if windows or osx:
raise Exception('--drm is Linux only')
# And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package
# straight from `target/release` without bundling libdrmtap, without the rename, without
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
# injection. The separate package name is the informed consent this feature rests on, so
# refuse rather than ship a stock-named build of it.
branch = linux_packaging_branch()
if branch != 'deb':
raise Exception(
f'--drm is only supported on the deb packaging path; this host would package via '
f'{branch}, which cannot bundle libdrmtap or name the package distinctly')
features.append('drm')
# The display wake is its own compile gate on top of `drm`, and the unattended package is
# exactly where it belongs: that variant exists to reach a machine nobody is sitting at,
# and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from
# this line builds the same capture backend with no wake code in the binary at all.
# It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE.
features.append('drm-wake')
if osx: if osx:
if args.screencapturekit: if args.screencapturekit:
features.append('screencapturekit') features.append('screencapturekit')
@@ -300,7 +364,7 @@ Version: %s
Architecture: %s Architecture: %s
Maintainer: rustdesk <info@rustdesk.com> Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com Homepage: https://rustdesk.com
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1 Recommends: libayatana-appindicator3-1
Description: A remote control software. Description: A remote control software.
@@ -316,6 +380,322 @@ def ffi_bindgen_function_refactor():
'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart') 'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')
# libdrmtap is fetched at build time from the rustdesk-org fork at a pinned
# commit — the same way rustdesk sources its other native build deps (vcpkg,
# flutter_rust_bridge, ...), rather than carrying a git submodule. It is the ONLY
# pin for the drm backend: rustdesk dlopens this .so at runtime and does not depend on
# the libdrmtap-sys crate (whose build.rs would statically link the C tree, a helper and
# libdrm/seccomp/cap). DRMTAP_REPO, DRMTAP_SHA and DRMTAP_PREBUILT_DIR override it for local testing
# or another fork, and each requires DRMTAP_ALLOW_UNPINNED=1 alongside it (see below).
# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see
# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in
# any workflow, so a bump is one edit here (plus the informational version comment in
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.4.
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
LIBDRMTAP_SHA_PINNED = '5da68a3a368db569716d0d0f11cefacbb11b2290'
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED)
LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the
# claim this feature rests on -- that the privileged capture library is the reviewed object at
# LIBDRMTAP_SHA_PINNED -- would hold only as long as nobody happened to have one of these set, and a
# build that silently used something else would be indistinguishable from one that did not.
# DRMTAP_PREBUILT_DIR is in the list because it is the widest of the three: it skips both the fetch
# and the sha verification and hands over an object built from nothing this script can see.
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir):
# A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object,
# not an override, so it must not need the opt-in. This is how CI hands the library from a step
# that has meson to a packaging container that does not.
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
try:
inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src
except ValueError:
return False
if not inside or not os.path.isdir(os.path.join(src, '.git')):
return False
try:
head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
except (subprocess.SubprocessError, OSError):
return False
return head == LIBDRMTAP_SHA
def _validate_libdrmtap_pin():
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
# environment (or a malformed sha) must not be able to fail a build that never touches
# libdrmtap.
# `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(),
# which tests it for truthiness.
prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None
if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt):
prebuilt = None
overridden = [
name
for name, value, pinned in (
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
('DRMTAP_PREBUILT_DIR', prebuilt, None),
)
if value != pinned
]
if overridden and not DRMTAP_UNPINNED_OK:
raise Exception(
f'{", ".join(overridden)} would build libdrmtap from something other than the pinned '
f'{LIBDRMTAP_REPO_PINNED} at {LIBDRMTAP_SHA_PINNED}. That is supported for local work and '
'cross-builds, but it has to be deliberate: set DRMTAP_ALLOW_UNPINNED=1 as well.')
if overridden:
print(f'WARNING: libdrmtap is NOT the pinned build ({", ".join(overridden)} set)')
# Both are interpolated into shell commands below, and both are env-overridable, so validate
# their SHAPE before they get there. This is not only about a hostile environment: a truncated
# or abbreviated sha would otherwise reach `git fetch` and fail with something far less obvious
# than saying so here, and an abbreviated one would defeat the point of pinning.
if not re.fullmatch(r'[0-9a-f]{40}', LIBDRMTAP_SHA):
raise Exception(
f'DRMTAP_SHA must be a full 40-character commit sha, got {LIBDRMTAP_SHA!r}')
if not re.fullmatch(r'(https://|git@)[A-Za-z0-9._~:/@-]+', LIBDRMTAP_REPO):
raise Exception(f'DRMTAP_REPO does not look like a git remote url: {LIBDRMTAP_REPO!r}')
def _single_real_so(paths, where):
# Return the one real libdrmtap.so.0.* object among `paths`, failing if there are zero or several.
# glob order is arbitrary, so silently taking [0] could ship a stale or wrong-arch object left
# over from an earlier build; a mismatch should fail the build loudly instead.
real = sorted(p for p in paths if os.path.isfile(p) and not os.path.islink(p))
if len(real) != 1:
raise Exception(
f'expected exactly one real libdrmtap.so.0.* in {where}, found {len(real)}: {real}')
return real[0]
def build_libdrmtap_so():
# Build libdrmtap.so from the rustdesk-org fork, fetched at the pinned LIBDRMTAP_SHA. The
# pivot dlopen-s this .so in-process in the root service (which already holds
# CAP_SYS_ADMIN) — no setcap helper, no privileged child. Only the shared
# library target is built (the source also carries a helper binary we do not
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
_validate_libdrmtap_pin()
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
if prebuilt_dir:
# DRMTAP_PREBUILT_DIR explicitly names the artifact source, so honor it strictly: fail
# (rather than silently falling back to a source build) if it holds no single real .so.
prebuilt = glob.glob(os.path.join(prebuilt_dir, 'libdrmtap.so.0.*'))
so = _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}')
# Check the stub case HERE too, not only on the source path below. This is the widest
# override of the three -- no fetch, no sha verification, an object built by something this
# script cannot see -- so it is the likeliest to hand over a CPU-only build, and skipping the
# assertion on exactly this path would leave the check guarding only the case that was
# already trustworthy.
_assert_so_has_egl(so)
return so
# Fetch the pinned source if it is not already present. third_party/libdrmtap is not a submodule
# anymore; it is git-ignored. The commit is fetched BY SHA rather than by cloning a branch:
# `clone --depth 1 --branch main` only ever fetches the tip, so the moment upstream pushes to
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
# is immune to a ref being moved or repointed.
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
if not os.path.exists(os.path.join(src, 'meson.build')):
if os.path.isdir(src):
shutil.rmtree(src)
os.makedirs(src, exist_ok=True)
system2(f'git -C "{src}" init -q')
system2(f'git -C "{src}" remote add origin {LIBDRMTAP_REPO}')
system2(f'git -C "{src}" fetch --depth 1 origin {LIBDRMTAP_SHA}')
system2(f'git -C "{src}" checkout -q FETCH_HEAD')
# Verify the pin whenever the source is a GIT checkout. A fetch by sha cannot resolve to anything
# else, so this now guards the OTHER case: a reused checkout left by an earlier build at a
# different pin, which is what a bump leaves behind. Reject and remove it so the next run re-fetches
# cleanly. A NON-git tree placed here on purpose (a developer building unreleased local libdrmtap
# source) has nothing to verify and is used as-is.
if os.path.isdir(os.path.join(src, '.git')):
got_sha = subprocess.check_output(
['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
if got_sha != LIBDRMTAP_SHA:
shutil.rmtree(src, ignore_errors=True)
raise Exception(
f'libdrmtap at {src} is {got_sha}, expected {LIBDRMTAP_SHA} '
f'(stale checkout from a different pin; removed, re-run to re-fetch)')
build_dir = os.path.join(src, 'build-pkg')
if not os.path.exists(os.path.join(build_dir, 'build.ninja')):
system2(f'meson setup "{build_dir}" "{src}" --buildtype=release')
# Build only the shared library, not the bundled helper binary or the static archive. Since
# libdrmtap 0.4.11 the project is `both_libraries` (a version-scripted .so + a static .a), so the
# bare `drmtap` target is ambiguous ("drmtap:shared_library" vs "drmtap:static_library"); ask for
# the shared one explicitly (rustdesk dlopens the .so and never needs the archive).
system2(f'meson compile -C "{build_dir}" drmtap:shared_library')
sos = glob.glob(os.path.join(build_dir, 'libdrmtap.so.0.*'))
# keep the real object (libdrmtap.so.0.4.x), not the .so/.so.0 symlinks or meson's .p dir, and
# require exactly one so a stale object from an earlier build is never silently picked.
so = _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}')
_assert_so_has_egl(so)
return so
def _assert_so_has_egl(so_path):
# libdrmtap treats egl/glesv2 as OPTIONAL dependencies: without their headers and pkg-config
# files, meson silently builds a CPU-only stub. That stub still exports every symbol the loader
# checks for, so nothing downstream notices -- and the split architecture depends entirely on the
# unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture
# quietly degrades to PipeWire on every tiled-scanout host, which is most of them.
#
# Assert on the ARTIFACT rather than passing an option that demands it: `-Degl=enabled` exists
# only in libdrmtap past 0.4.15, and checking what was actually produced also catches a stale or
# hand-substituted object, which a build flag cannot.
#
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
# lacks.
try:
with open(so_path, 'rb') as f:
blob = f.read()
except OSError as err:
raise Exception(f'cannot read the built libdrmtap at {so_path}: {err}') from err
missing = [m for m in (b'libEGL.so.1', b'eglCreateImageKHR') if m not in blob]
if missing:
raise Exception(
f'{so_path} looks like a CPU-only libdrmtap stub (missing '
f'{", ".join(m.decode() for m in missing)}): the EGL detile path the split capture '
'depends on is not in it, and DRM capture would silently fall back to PipeWire. '
'Install the EGL development packages and rebuild (Debian/Ubuntu: libegl-dev '
'libgles2-mesa-dev; Arch: mesa libglvnd).')
DRM_PACKAGE_NAME = 'rustdesk-unattended-wayland'
def assert_so_satisfies_the_runtime_abi_gate(so_path):
"""The .so we are about to ship must be one the RUNTIME will actually accept.
`abi_accepted` in libs/scrap/src/common/drmtap_dl.rs is the only place the pinned library's
version is ever validated, and it runs at dlopen time on the USER's machine. Nothing in the
build or in CI compared the two, so the pin and the gate could drift apart and every existing
assertion would still pass: the EGL check does not look at the version, the CI symbol contract
does not call drmtap_version(), and the deb-contents regex matches any `libdrmtap.so.0.X.Y`.
A green pipeline could therefore produce a deb in which DRM capture can never start, and the
only symptom on the host is one log line before it falls back to the portal.
So parse the gate out of the Rust and apply it here, to the object being staged. This is the
same rule, not a copy of the numbers: if someone bumps the constants, this reads the new ones.
"""
m = re.search(r'libdrmtap\.so\.(\d+)\.(\d+)\.(\d+)', os.path.basename(so_path))
if not m:
# Not a versioned soname (a local dev build, say). The gate cannot be evaluated, and
# inventing a verdict would be worse than saying so.
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
return
so_ver = tuple(int(g) for g in m.groups())
# REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now.
gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
with open(gate_path) as f:
gate_src = f.read()
def _const(name):
mm = re.search(rf'const {name}: c_int = (\d+);', gate_src)
return int(mm.group(1)) if mm else None
major, minor = _const('DRMTAP_ABI_MAJOR'), _const('DRMTAP_ABI_MINOR')
mm = re.search(r'const DRMTAP_MIN_MINOR_PATCH: \(c_int, c_int\) = \((\d+), (\d+)\);', gate_src)
floor = (int(mm.group(1)), int(mm.group(2))) if mm else None
if major is None or minor is None or floor is None:
raise Exception(
'could not parse the libdrmtap ABI gate out of drmtap_dl.rs (DRMTAP_ABI_MAJOR / '
'DRMTAP_ABI_MINOR / DRMTAP_MIN_MINOR_PATCH). The gate moved and this check did not; '
'fix the check rather than removing it, or the pin and the gate can drift silently.')
accepted = so_ver[0] == major and so_ver[1] == minor and (so_ver[1], so_ver[2]) >= floor
if not accepted:
raise Exception(
f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the '
f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor '
f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never '
'start. Move the build pin and the gate together, or fix whichever one is wrong.')
print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate '
f'(major {major}, minor {minor}, patch >= {floor[1]})')
def stage_libdrmtap_into_deb(so_path):
# Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname
# symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at
# the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the
# system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system
# library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in
# and no ldconfig trigger are shipped, so the stock postinst is used unchanged.
assert_so_satisfies_the_runtime_abi_gate(so_path)
so_basename = os.path.basename(so_path)
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
# Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can
# contain a space, and an unquoted interpolation would split the argument and fail obscurely.
system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/')
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
def _max_glibc_minor(path):
# Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because
# librustdesk.so is ~45 MB.
best = 0
with open(path, 'rb') as f:
tail = b''
while True:
chunk = f.read(1 << 20)
if not chunk:
return best
for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk):
best = max(best, int(m.group(1)))
tail = chunk[-16:]
def measured_glibc_floor():
# libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged
# object is higher -- and it moves whenever either base does.
paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*')
+ glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk')
if os.path.isfile(p) and not os.path.islink(p)]
minor = max((_max_glibc_minor(p) for p in paths), default=0)
if not minor:
raise Exception(
f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); '
'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which '
'is what lets it install on a host where libdrmtap can never load')
return f'2.{minor}'
def retarget_control_to_drm_variant():
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
# this variant lives here. The variant installs the same files as the stock package, so it must
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
# own runtime deps, which the stock package has no reason to carry.
path = '../res/DEBIAN/control'
floor = measured_glibc_floor()
print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}')
with open(path) as f:
lines = f.readlines()
out = []
for line in lines:
if line.startswith('Package: rustdesk'):
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
elif line.startswith('Depends:'):
# 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture.
out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, '
f'libc6 (>= {floor})\n')
else:
out.append(line)
body = ''.join(out)
# Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file
# that stopped matching either anchor would otherwise produce a variant deb wearing the stock name.
if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body:
raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed')
with open(path, 'w') as f:
f.write(body)
def build_flutter_deb(version, features): def build_flutter_deb(version, features):
if not skip_cargo: if not skip_cargo:
system2(f'cargo build --locked --features {features} --lib --release') system2(f'cargo build --locked --features {features} --lib --release')
@@ -324,8 +704,6 @@ def build_flutter_deb(version, features):
system2('flutter build linux --release') system2('flutter build linux --release')
system2('mkdir -p tmpdeb/usr/bin/') system2('mkdir -p tmpdeb/usr/bin/')
system2('mkdir -p tmpdeb/usr/share/rustdesk') system2('mkdir -p tmpdeb/usr/share/rustdesk')
system2('mkdir -p tmpdeb/etc/rustdesk/')
system2('mkdir -p tmpdeb/etc/pam.d/')
system2('mkdir -p tmpdeb/usr/share/rustdesk/files/systemd/') system2('mkdir -p tmpdeb/usr/share/rustdesk/files/systemd/')
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/256x256/apps/') system2('mkdir -p tmpdeb/usr/share/icons/hicolor/256x256/apps/')
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/scalable/apps/') system2('mkdir -p tmpdeb/usr/share/icons/hicolor/scalable/apps/')
@@ -344,17 +722,24 @@ def build_flutter_deb(version, features):
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') 'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2( system2(
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
'cp ../res/startwm.sh tmpdeb/etc/rustdesk/')
system2(
'cp ../res/xorg.conf tmpdeb/etc/rustdesk/')
system2(
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
system2( system2(
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
# Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages
# stay exactly what they were. The root service dlopens it in-process by absolute path.
# `features` is the comma-joined string, so split it: a bare substring test would also match any
# future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the
# consent-bypass variant without --drm ever being passed.
ships_so = 'drm' in features.split(',')
if ships_so:
# Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the
# binary, so `features` says what was ASKED for while the staged bundle can be anything.
assert_staged_binary_is_drm()
stage_libdrmtap_into_deb(build_libdrmtap_so())
system2('mkdir -p tmpdeb/DEBIAN') system2('mkdir -p tmpdeb/DEBIAN')
generate_control_file(version) generate_control_file(version)
if ships_so:
retarget_control_to_drm_variant()
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
md5_file_folder("tmpdeb/") md5_file_folder("tmpdeb/")
system2('dpkg-deb -b tmpdeb rustdesk.deb;') system2('dpkg-deb -b tmpdeb rustdesk.deb;')
@@ -362,10 +747,68 @@ def build_flutter_deb(version, features):
system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf tmpdeb/')
system2('/bin/rm -rf ../res/DEBIAN/control') system2('/bin/rm -rf ../res/DEBIAN/control')
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
if ships_so:
# Named apart from the stock package so installing the consent-free variant is a deliberate act.
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
os.chdir("..") os.chdir("..")
def build_deb_from_folder(version, binary_folder): DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0'
# Present only when `drm-wake` is compiled in: the runtime option constant is itself
# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it -
# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that
# is exactly the deb this assertion is here to refuse.
DRMTAP_WAKE_MARKER = b'enable-drm-display-wake'
def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER):
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary:
# librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with`
# closes deterministically instead of relying on refcounting.
with open(path, 'rb') as f:
tail = b''
while True:
chunk = f.read(1 << 20)
if not chunk:
return False
if marker in tail + chunk:
return True
tail = chunk[-(len(marker) - 1):]
def assert_staged_binary_is_drm():
"""The staged BINARY must really be a drm build before it is named the unattended-wayland
variant. That package conflicts with and replaces the stock one, so shipping a stock binary
under that name produces something that can never capture and cannot be installed alongside
what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when
the feature is compiled in -- assert what was produced, not what was asked for.
Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which
is how CI packages) reaches the other, where nothing had rebuilt the binary at all.
"""
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
if not any(_carries_drmtap_marker(p) for p in binaries):
raise Exception(
f'--drm was requested but the staged bundle does not look like a drm build (no '
f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); '
'refusing to package it as the unattended-wayland variant, which conflicts with and '
'replaces the stock package but could never capture')
# And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and
# documented as the variant that can reach a machine whose screen has gone dark. The dlopen
# marker above does not distinguish them: `--features drm` alone carries it and has no wake code
# at all. Asserting only the first half is how a deb can be named for a feature it does not have.
if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries):
raise Exception(
f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} '
f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; '
'refusing to package it as the unattended-wayland variant, which is named and '
'documented as the build that can wake an idle-disabled display. If this fired under '
'--skip-cargo, the cargo line that produced the bundle is missing the feature: '
'--features ...,drm,drm-wake')
def build_deb_from_folder(version, binary_folder, want_drm=False):
os.chdir('flutter') os.chdir('flutter')
system2('mkdir -p tmpdeb/usr/bin/') system2('mkdir -p tmpdeb/usr/bin/')
system2('mkdir -p tmpdeb/usr/share/rustdesk') system2('mkdir -p tmpdeb/usr/share/rustdesk')
@@ -389,9 +832,53 @@ def build_deb_from_folder(version, binary_folder):
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2( system2(
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
# Where the capture library comes from for a `--package <folder> --drm` build. Two shapes are
# supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.*
# (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path
# here actually produces -- the flutter deb builds the library straight into the staged deb, so
# nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag
# combination impossible to satisfy.
bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*')
bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob)
# The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be
# staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when
# --drm was never passed.
if bundle_carries_so and not want_drm:
raise Exception(
'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing '
'to silently ship the consent-bypass unattended-wayland variant (pass --drm to '
'build it deliberately)')
if want_drm:
# Whichever shape we are in, the staged BINARY must really be a drm build. This is the
# property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as
# the unattended-wayland variant would carry the consent-bypass name, conflict with and
# replace the stock package, and never be able to capture. The marker is the absolute
# dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same
# kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what
# was produced, not what was asked for.
assert_staged_binary_is_drm()
if bundle_carries_so:
so = _single_real_so(bundled_glob, 'the staged --drm bundle')
# The THIRD artifact source, and the last one that was missing the check: --package
# takes the .so straight out of a bundle somebody else produced, so it has the same
# exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub
# would ship, the loader would accept it, and capture would degrade to PipeWire
# without a word.
_assert_so_has_egl(so)
stage_libdrmtap_into_deb(so)
system2(f'rm -f "{so}"')
system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0')
else:
# Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the
# EGL backend itself). The library is independent of the staged binary.
stage_libdrmtap_into_deb(build_libdrmtap_so())
system2('mkdir -p tmpdeb/DEBIAN') system2('mkdir -p tmpdeb/DEBIAN')
generate_control_file(version) generate_control_file(version)
# Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has
# its library in tmpdeb whichever of the two shapes it came from.
if want_drm:
retarget_control_to_drm_variant()
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
md5_file_folder("tmpdeb/") md5_file_folder("tmpdeb/")
system2('dpkg-deb -b tmpdeb rustdesk.deb;') system2('dpkg-deb -b tmpdeb rustdesk.deb;')
@@ -399,6 +886,8 @@ def build_deb_from_folder(version, binary_folder):
system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf tmpdeb/')
system2('/bin/rm -rf ../res/DEBIAN/control') system2('/bin/rm -rf ../res/DEBIAN/control')
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
if want_drm:
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
os.chdir("..") os.chdir("..")
@@ -473,6 +962,19 @@ def main():
parser = make_parser() parser = make_parser()
args = parser.parse_args() args = parser.parse_args()
# Before anything with a side effect: this is a query, and a caller uses it to build the very
# binary it will then package. `get_features` stays the single definition of what a flag
# combination means; a caller that hardcodes the list instead is one edit away from compiling
# something other than what it ships.
if args.print_features:
# stdout carries the list and nothing else, so a caller can use it directly in a command
# substitution. `get_features` prints a human-readable line of its own; send that to stderr
# for this call rather than silencing it, which would change what every other path prints.
with contextlib.redirect_stdout(sys.stderr):
feats = ','.join(get_features(args))
print(feats)
return
if os.path.exists(exe_path): if os.path.exists(exe_path):
os.unlink(exe_path) os.unlink(exe_path)
if os.path.isfile('/usr/bin/pacman'): if os.path.isfile('/usr/bin/pacman'):
@@ -488,7 +990,7 @@ def main():
portable = args.portable portable = args.portable
package = args.package package = args.package
if package: if package:
build_deb_from_folder(version, package) build_deb_from_folder(version, package, args.drm)
return return
res_dir = 'resources' res_dir = 'resources'
external_resources(flutter, args, res_dir) external_resources(flutter, args, res_dir)
@@ -622,13 +1124,7 @@ def main():
'cp res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') 'cp res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2( system2(
'cp res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') 'cp res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
os.system('mkdir -p tmpdeb/etc/rustdesk/')
os.system('cp -a res/startwm.sh tmpdeb/etc/rustdesk/')
os.system('mkdir -p tmpdeb/etc/X11/rustdesk/')
os.system('cp res/xorg.conf tmpdeb/etc/X11/rustdesk/')
os.system('cp -a DEBIAN/* tmpdeb/DEBIAN/') os.system('cp -a DEBIAN/* tmpdeb/DEBIAN/')
os.system('mkdir -p tmpdeb/etc/pam.d/')
os.system('cp pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
system2('strip tmpdeb/usr/bin/rustdesk') system2('strip tmpdeb/usr/bin/rustdesk')
system2('mkdir -p tmpdeb/usr/share/rustdesk') system2('mkdir -p tmpdeb/usr/share/rustdesk')
system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/rustdesk/') system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/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() { fn install_android_deps() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
if target_os != "android" { if target_os != "android" {
@@ -72,7 +81,6 @@ fn install_android_deps() {
path.join("lib").to_str().unwrap() path.join("lib").to_str().unwrap()
); );
println!("cargo:rustc-link-lib=ndk_compat"); println!("cargo:rustc-link-lib=ndk_compat");
println!("cargo:rustc-link-lib=oboe");
println!("cargo:rustc-link-lib=c++"); println!("cargo:rustc-link-lib=c++");
println!("cargo:rustc-link-lib=OpenSLES"); println!("cargo:rustc-link-lib=OpenSLES");
} }
@@ -90,5 +98,8 @@ fn main() {
build_mac(); build_mac();
println!("cargo:rustc-link-lib=framework=ApplicationServices"); println!("cargo:rustc-link-lib=framework=ApplicationServices");
} }
if target_os == "android" {
build_android_ifaddrs();
}
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
} }

View File

@@ -107,7 +107,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban ### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community **Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals. individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within **Consequence**: A permanent ban from any sort of public interaction within

View File

@@ -24,7 +24,7 @@ Untuk instruksi Git yang lebih lanjut, cek disini [GitHub workflow 101](https://
## Tindakan ## Tindakan
<https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-ID.md> <https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md>
## Komunikasi ## Komunikasi

View File

@@ -30,7 +30,7 @@ Per istruzioni specifiche su git, vedi [Workflow GitHub - 101](https://github.co
## Condotta ## Condotta
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-IT.md https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
## Comunicazioni ## Comunicazioni

View File

@@ -1,6 +1,6 @@
# Contributing to RustDesk # Contributing to RustDesk
RustDesk welcomes contribution from everyone. Here are the guidelines if you are RustDesk welcomes contributions from everyone. Here are the guidelines if you are
thinking of helping us: thinking of helping us:
## Contributions ## Contributions

View File

@@ -160,7 +160,6 @@ RustDesk يرجى التأكد من أنك تنفذ هذه الأوامر من
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: أو المنقول عن بُعد (TCP hole punching) انتظر الاتصال المباشر [rustdesk-server](https://github.com/rustdesk/rustdesk-server) الإتصال ب - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: أو المنقول عن بُعد (TCP hole punching) انتظر الاتصال المباشر [rustdesk-server](https://github.com/rustdesk/rustdesk-server) الإتصال ب
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: رمز خاص بكل منصة - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: رمز خاص بكل منصة
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**:Flutter لعميل الويب الخاص ب Javascript
## لقطات ## لقطات

View File

@@ -144,7 +144,6 @@ Ujistěte se, že tyto příkazy spouštíte z kořenového adresáře RustDesk,
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: komunikace s [rustdesk-server](https://github.com/rustdesk/rustdesk-server), očekávání vzdálených příméhých („proděrováváním“ TCP) nebo předávaných (relay) spojení - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: komunikace s [rustdesk-server](https://github.com/rustdesk/rustdesk-server), očekávání vzdálených příméhých („proděrováváním“ TCP) nebo předávaných (relay) spojení
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: zdrojové kódy, specifické pro jednotlivé platformy - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: zdrojové kódy, specifické pro jednotlivé platformy
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: zdrojové kódy pro použití s aplikačním rámcem (framework) Flutter pro mobilní platformy - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: zdrojové kódy pro použití s aplikačním rámcem (framework) Flutter pro mobilní platformy
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript pro Flutter webový klient
## Ukázky ## Ukázky

View File

@@ -66,19 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -168,7 +168,6 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Mit [rustdesk-server](https://github.com/rustdesk/rustdesk-server) kommunizieren, warten auf direkte (TCP hole punching) oder weitergeleitete Verbindung - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Mit [rustdesk-server](https://github.com/rustdesk/rustdesk-server) kommunizieren, warten auf direkte (TCP hole punching) oder weitergeleitete Verbindung
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: Plattformspezifischer Code - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: Plattformspezifischer Code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter-Code für Handys - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter-Code für Handys
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript für Flutter-Webclient
## Screenshots ## Screenshots

View File

@@ -62,19 +62,19 @@ Por favor descarga la librería dinámica de Sciter tú mismo.
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -163,7 +163,6 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicación con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), esperar la conexión remota directa ("TCP hole punching") o conexión indirecta ("relayed") - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicación con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), esperar la conexión remota directa ("TCP hole punching") o conexión indirecta ("relayed")
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter
> [!Precaución] > [!Precaución]
> **Descargo de responsabilidad por uso indebido:** <br> > **Descargo de responsabilidad por uso indebido:** <br>

View File

@@ -146,7 +146,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
## تصاویر محیط نرم‌افزار ## تصاویر محیط نرم‌افزار

View File

@@ -158,7 +158,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client
## Στιγμιότυπα ## Στιγμιότυπα

View File

@@ -48,7 +48,7 @@ A telefonos verziók Flutter-t hasznának. Később lehetséges hogy Sciterről
- Futtasd a `cargo run` parancsot - Futtasd a `cargo run` parancsot
## [Építés](https://rustdesk.com/docs/hu/dev/build/) ## [Építés](https://rustdesk.com/docs/en/dev/build/)
## Hogyan építs Linuxon ## Hogyan építs Linuxon
@@ -150,7 +150,6 @@ Kérlek mindenképpen nézd meg hogy ezeket a parancsokat a root RustDesk mappá
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
## Képernyőképek ## Képernyőképek

View File

@@ -162,7 +162,6 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunica con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attende la connessione remota diretta (TCP hole punching) oppure indiretta (relayed) - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunica con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attende la connessione remota diretta (TCP hole punching) oppure indiretta (relayed)
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: codice specifico della piattaforma - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: codice specifico della piattaforma
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter
> [!Attenzione] > [!Attenzione]
> **Dichiarazione di non responsabilità per uso improprio:** <br> > **Dichiarazione di non responsabilità per uso improprio:** <br>

View File

@@ -166,7 +166,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う。 - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う。
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript
> [!注意] > [!注意]
> **:不正使用に関する免責事項** <br> > **:不正使用に関する免責事項** <br>

View File

@@ -66,19 +66,19 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -168,7 +168,6 @@ RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신, 원격 다이렉트 (TCP 홀 펀칭) 또는 릴레이 연결 대기 - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신, 원격 다이렉트 (TCP 홀 펀칭) 또는 릴레이 연결 대기
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼별 코드 - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼별 코드
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 데스크톱 및 모바일용 Flutter 코드 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 데스크톱 및 모바일용 Flutter 코드
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter 웹 클라이언트용 JavaScript
## 스크린샷 ## 스크린샷

View File

@@ -62,19 +62,19 @@ Venligst last ned Sciters dynamiske bibliotek selv.
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -163,7 +163,6 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient
## Skjermbilder ## Skjermbilder

View File

@@ -155,7 +155,6 @@ Upewnij się, że uruchamiasz te polecenia z katalogu głównego repozytorium Ru
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikacja z [rustdesk-server](https://github.com/rustdesk/rustdesk-server), czekanie na bezpośrednie (odpytywanie TCP) lub przekazywane połączenie - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikacja z [rustdesk-server](https://github.com/rustdesk/rustdesk-server), czekanie na bezpośrednie (odpytywanie TCP) lub przekazywane połączenie
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kod specyficzny dla danej platformy - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kod specyficzny dla danej platformy
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: kod Flutter dla urządzeń mobilnych - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: kod Flutter dla urządzeń mobilnych
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript dla Flutter - klient web
## Zrzuty ekranu ## Zrzuty ekranu

View File

@@ -64,19 +64,19 @@ Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria
### Ubuntu 18 (Debian 10) ### Ubuntu 18 (Debian 10)
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -166,7 +166,6 @@ Certifique-se de executar esses comandos a partir da raiz do repositório do Rus
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida. - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma. - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis. - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
## Capturas de Tela ## Capturas de Tela

View File

@@ -66,19 +66,19 @@ Te rugăm să descarci singur librăria dinamică Sciter.
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -168,7 +168,6 @@ Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, a
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web
## Capturi de ecran ## Capturi de ecran

View File

@@ -59,7 +59,7 @@ RustDesk приветствует вклад каждого. Ознакомьт
- Выполните команду `cargo run` - Выполните команду `cargo run`
## [Сборка](https://rustdesk.com/docs/ru/dev/build/) ## [Сборка](https://rustdesk.com/docs/en/dev/build/)
## Как собрать на Linux ## Как собрать на Linux
@@ -68,19 +68,19 @@ RustDesk приветствует вклад каждого. Ознакомьт
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -170,7 +170,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
## Скриншоты ## Скриншоты
@@ -180,4 +179,4 @@ target/release/rustdesk
![Передача файлов](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) ![Передача файлов](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) ![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)

View File

@@ -166,7 +166,6 @@ Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığın
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript
## Ekran Görüntüleri ## Ekran Görüntüleri

View File

@@ -59,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT
```sh ```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
``` ```
### openSUSE Tumbleweed ### openSUSE Tumbleweed
```sh ```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -160,7 +160,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для веб клієнта на Flutter
## Знімки екрана ## Знімки екрана

View File

@@ -148,7 +148,6 @@ Hãy đảm bảo rằng bạn đang chạy các lệnh này từ gốc của th
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: giao tiếp với [rustdesk-server](https://github.com/rustdesk/rustdesk-server), đợi kết nối trực tiếp (TCP hole punching) hoặc kết nối được chuyển tiếp. - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: giao tiếp với [rustdesk-server](https://github.com/rustdesk/rustdesk-server), đợi kết nối trực tiếp (TCP hole punching) hoặc kết nối được chuyển tiếp.
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: mã nguồn riêng cho mỗi nền tảng - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: mã nguồn riêng cho mỗi nền tảng
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Mã JavaScript dành cho giao diện trên web bằng Flutter
## Snapshot ## Snapshot

View File

@@ -220,7 +220,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继) - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继)
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码 - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码
## 截图 ## 截图

View File

@@ -0,0 +1,11 @@
Aplicación de escritorio remoto de código abierto, la alternativa open source a TeamViewer.
Código fuente: https://github.com/rustdesk/rustdesk
Documentación: https://rustdesk.com/docs/en/manual/mobile/
Para que un dispositivo remoto controle tu Android mediante el ratón o el tacto, debes permitir que RustDesk utilice el servicio de "Accesibilidad". RustDesk utiliza la API AccessibilityService para implementar el control remoto en Android.
Además del control remoto, también puedes transferir archivos fácilmente entre dispositivos Android y ordenadores mediante RustDesk.
Tienes control total de tus datos, sin preocupaciones de seguridad. Puedes utilizar nuestro servidor rendezvous/relay, optar por el autoalojamiento o escribir tu propio servidor rendezvous/relay. El servidor autoalojado es gratuito y de código abierto: https://github.com/rustdesk/rustdesk-server
Descarga e instala la versión de escritorio desde: https://rustdesk.com — entonces podrás acceder y controlar tu ordenador desde tu teléfono, o controlar tu teléfono desde tu ordenador.

View File

@@ -0,0 +1 @@
Aplicación de acceso remoto de código abierto, alternativa a TeamViewer.

View File

@@ -0,0 +1,11 @@
Aplicativo de desktop remoto de código aberto, a alternativa open source ao TeamViewer.
Código-fonte: https://github.com/rustdesk/rustdesk
Documentação: https://rustdesk.com/docs/pt/client/android/
Para que um dispositivo remoto controle seu Android via mouse ou toque, você precisa permitir que o RustDesk utilize o serviço de "Acessibilidade". O RustDesk usa a API AccessibilityService para implementar o controle remoto no Android.
Além do controle remoto, você também pode transferir arquivos entre dispositivos Android e PCs com facilidade usando o RustDesk.
Você tem controle total dos seus dados, sem preocupações com a segurança. Você pode usar nosso servidor rendezvous/relay, optar pela auto-hospedagem ou criar seu próprio servidor de rendezvous/relay. O servidor auto-hospedado é gratuito e open source: https://github.com/rustdesk/rustdesk-server
Baixe e instale a versão para desktop em: https://rustdesk.com — então você poderá acessar e controlar seu computador pelo celular ou controlar seu celular pelo computador.

View File

@@ -0,0 +1 @@
Aplicativo de acesso remoto open source, alternativa ao TeamViewer.

View File

@@ -21,18 +21,6 @@
} }
] ]
}, },
{
"name": "pam",
"buildsystem": "autotools",
"config-opts": ["--disable-selinux"],
"sources": [
{
"type": "archive",
"url": "https://github.com/linux-pam/linux-pam/releases/download/v1.3.1/Linux-PAM-1.3.1.tar.xz",
"sha256": "eff47a4ecd833fbf18de9686632a70ee8d0794b79aecb217ebd0ce11db4cd0db"
}
]
},
{ {
"name": "rustdesk", "name": "rustdesk",
"buildsystem": "simple", "buildsystem": "simple",
@@ -63,4 +51,4 @@
"--socket=pulseaudio", "--socket=pulseaudio",
"--talk-name=org.freedesktop.Flatpak" "--talk-name=org.freedesktop.Flatpak"
] ]
} }

View File

@@ -82,15 +82,17 @@ protobuf {
} }
android { android {
compileSdkVersion 34 namespace "com.carriez.flutter_hbb"
compileSdkVersion 36
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
main.proto.srcDirs += '../../../libs/hbb_common/protos' main.proto.srcDirs += '../../../libs/base/protos'
main.proto.includes += "message.proto" main.proto.includes += "message.proto"
} }
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
} }
@@ -99,7 +101,7 @@ android {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.carriez.flutter_hbb" applicationId "com.carriez.flutter_hbb"
minSdkVersion 22 minSdkVersion 22
targetSdkVersion 33 targetSdkVersion 36
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }
@@ -128,6 +130,7 @@ flutter {
} }
dependencies { dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation 'com.google.protobuf:protobuf-javalite:3.20.1' implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
implementation "androidx.media:media:1.6.0" implementation "androidx.media:media:1.6.0"
implementation 'com.github.getActivity:XXPermissions:18.5' implementation 'com.github.getActivity:XXPermissions:18.5'

View File

@@ -1,15 +1,19 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.carriez.flutter_hbb"> package="com.carriez.flutter_hbb">
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -26,7 +30,6 @@
android:name=".MainApplication" android:name=".MainApplication"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="RustDesk" android:label="RustDesk"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"> android:supportsRtl="true">
@@ -88,7 +91,12 @@
<service <service
android:name=".MainService" android:name=".MainService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="mediaProjection" /> android:exported="false"
android:foregroundServiceType="specialUse|mediaProjection|microphone">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="@string/foreground_service_special_use_subtype" />
</service>
<service <service
android:name=".FloatingWindowService" android:name=".FloatingWindowService"

View File

@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) { class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
private val logTag = "LOG_AUDIO_RECORD_HANDLE" companion object {
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
private const val NO_ACTIVE_PUBLISHERS = 0
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
@Synchronized
private fun acquireAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", true)
}
activeAudioFramePublishers++
}
@Synchronized
private fun releaseAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
Log.e(LOG_TAG, "No active audio frame publisher to release")
return
}
activeAudioFramePublishers--
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", false)
}
}
}
private val logTag = LOG_TAG
private var audioRecorder: AudioRecord? = null private var audioRecorder: AudioRecord? = null
private var audioReader: AudioReader? = null private var audioReader: AudioReader? = null
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return return
} }
// read f32 to byte , length * 4 // read f32 to byte , length * 4
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize( val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE, AUDIO_SAMPLE_RATE,
AUDIO_CHANNEL_MASK, AUDIO_CHANNEL_MASK,
AUDIO_ENCODING AUDIO_ENCODING
) )
if (minBufferSize == 0) { if (bufferSize <= 0) {
Log.d(logTag, "get min buffer size fail!") Log.d(logTag, "get min buffer size fail!")
return return
} }
audioReader = AudioReader(minBufferSize, 4) audioReader = AudioReader(bufferSize, 4)
minBufferSize = bufferSize
Log.d(logTag, "init audioData len:$minBufferSize") Log.d(logTag, "init audioData len:$minBufferSize")
} }
@RequiresApi(Build.VERSION_CODES.M) private fun releaseRecorder(recorder: AudioRecord) {
fun startAudioRecorder() { try {
checkAudioReader() recorder.release()
if (audioReader != null && audioRecorder != null && minBufferSize != 0) { } finally {
try { if (audioRecorder === recorder) {
FFI.setFrameRawEnable("audio", true) audioRecorder = null
audioRecorder!!.startRecording()
audioRecordStat = true
audioThread = thread {
while (audioRecordStat) {
audioReader!!.readSync(audioRecorder!!)?.let {
FFI.onAudioFrameUpdate(it)
}
}
// let's release here rather than onDestroy to avoid threading issue
audioRecorder?.release()
audioRecorder = null
minBufferSize = 0
FFI.setFrameRawEnable("audio", false)
Log.d(logTag, "Exit audio thread")
}
} catch (e: Exception) {
Log.d(logTag, "startAudioRecorder fail:$e")
} }
} else {
Log.d(logTag, "startAudioRecorder fail")
} }
} }
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
try {
while (audioRecordStat) {
reader.readSync(recorder)?.let {
FFI.onAudioFrameUpdate(it)
}
}
} finally {
minBufferSize = 0
try {
releaseRecorder(recorder)
} finally {
releaseAudioFramePublisher()
Log.d(logTag, "Exit audio thread")
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun startAudioRecorder(): Boolean {
val recorder = audioRecorder
if (recorder == null) {
Log.d(logTag, "startAudioRecorder fail")
return false
}
var audioFramePublisherAcquired = false
return try {
checkAudioReader()
val reader = audioReader
if (reader == null || minBufferSize == 0) {
releaseRecorder(recorder)
Log.d(logTag, "startAudioRecorder fail")
return false
}
recorder.startRecording()
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
throw IllegalStateException("AudioRecord failed to enter recording state")
}
audioRecordStat = true
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
acquireAudioFramePublisher()
audioFramePublisherAcquired = true
audioThread = captureThread
captureThread.start()
true
} catch (error: Exception) {
audioRecordStat = false
audioThread = null
Log.e(logTag, "startAudioRecorder fail", error)
try {
releaseRecorder(recorder)
} finally {
if (audioFramePublisherAcquired) {
releaseAudioFramePublisher()
}
}
false
}
}
fun isVoiceCallActive(): Boolean {
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
}
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean { fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
if (!isSupportVoiceCall()) { if (!isSupportVoiceCall()) {
return false return false
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
if (!isSupportVoiceCall()) { if (!isSupportVoiceCall()) {
return true return true
} }
if (isVideoStart()) { val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
switchOutVoiceCall(mediaProjection)
}
tryReleaseAudio() tryReleaseAudio()
return true return switched
} }
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail") Log.e(logTag, "createAudioRecorder fail")
return false return false
} }
startAudioRecorder() return startAudioRecorder()
return true
} }
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail") Log.e(logTag, "createAudioRecorder fail")
return false return false
} }
startAudioRecorder() return startAudioRecorder()
return true
} }
fun tryReleaseAudio() { fun tryReleaseAudio() {

View File

@@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
import ffi.FFI import ffi.FFI
import android.app.Activity
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
import android.media.MediaCodecList import android.media.MediaCodecList
import android.media.MediaFormat import android.media.MediaFormat
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import android.util.DisplayMetrics import android.util.DisplayMetrics
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import org.json.JSONArray import org.json.JSONArray
@@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlin.concurrent.thread import kotlin.concurrent.thread
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
@@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() {
private val channelTag = "mChannel" private val channelTag = "mChannel"
private val logTag = "mMainActivity" private val logTag = "mMainActivity"
private var mainService: MainService? = null private var mainService: MainService? = null
private sealed class PendingPicker {
data class ImportFiles(val result: MethodChannel.Result) : PendingPicker()
data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker()
data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker()
data class ExportFiles(
val sources: List<File>,
val rejected: Int,
val result: MethodChannel.Result
) : PendingPicker()
}
private data class ExportSource(
val file: File,
val children: List<ExportSource>?
)
private var pendingPicker: PendingPicker? = null
private var isAudioStart = false private var isAudioStart = false
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart }) private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
@@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ_IMPORT_FILES) {
val pending = pendingPicker as? PendingPicker.ImportFiles ?: return
pendingPicker = null
if (resultCode != Activity.RESULT_OK || data == null) {
pending.result.success(emptyList<Map<String, String>>())
return
}
val uris = linkedSetOf<Uri>()
data.data?.let { uris.add(it) }
data.clipData?.let { clipData ->
for (index in 0 until clipData.itemCount) {
uris.add(clipData.getItemAt(index).uri)
}
}
thread {
val files = uris.map { uri ->
mapOf(
"uri" to uri.toString(),
"name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty())
)
}
runOnUiThread { pending.result.success(files) }
}
return
}
if (requestCode == REQ_EXPORT_FILE) {
val pending = pendingPicker as? PendingPicker.ExportFile ?: return
pendingPicker = null
val destination = data?.data
if (resultCode != Activity.RESULT_OK || destination == null) {
pending.result.success(false)
return
}
thread {
try {
FileInputStream(pending.source).use { input ->
contentResolver.openOutputStream(destination, "wt")?.use { output ->
input.copyTo(output)
} ?: throw IllegalStateException("Unable to open the selected destination")
}
runOnUiThread { pending.result.success(true) }
} catch (e: Exception) {
Log.e(logTag, "Failed to export file", e)
runOnUiThread {
pending.result.error("export_failed", e.message, null)
}
}
}
return
}
if (requestCode == REQ_IMPORT_DIRECTORY) {
val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return
pendingPicker = null
val treeUri = data?.data
if (resultCode != Activity.RESULT_OK || treeUri == null) {
pending.result.success(null)
return
}
thread {
val selected = mapOf(
"uri" to treeUri.toString(),
"name" to (treeDisplayName(treeUri) ?: "Imported")
)
runOnUiThread { pending.result.success(selected) }
}
return
}
if (requestCode == REQ_EXPORT_FILES) {
val pending = pendingPicker as? PendingPicker.ExportFiles ?: return
pendingPicker = null
val treeUri = data?.data
if (resultCode != Activity.RESULT_OK || treeUri == null) {
pending.result.success(null)
return
}
thread {
var exported = 0
var failed = pending.rejected
var processed = 0
try {
val sources = pending.sources.map { snapshotExportSource(it) }
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
sources.forEach { source ->
val ok = source?.let {
copyExportSourceToTree(treeUri, rootDocId, it)
} ?: false
if (ok) exported++ else failed++
processed++
}
} catch (e: Exception) {
Log.e(logTag, "Failed to export selected files", e)
failed += pending.sources.size - processed
}
runOnUiThread {
pending.result.success(mapOf("exported" to exported, "failed" to failed))
}
}
return
}
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) { if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null) flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
} }
@@ -106,6 +233,16 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() { override fun onDestroy() {
Log.e(logTag, "onDestroy") Log.e(logTag, "onDestroy")
// The process can outlive the UI whenever something keeps it alive:
// MainService, or the accessibility InputService on its own. Only the
// former gets onTaskRemoved, so close outgoing sessions here too,
// otherwise a session survives with no UI left to close it.
// `isFinishing` distinguishes the user really leaving from a destroy
// for recreation (configuration change, "don't keep activities"),
// which must not tear down a live session.
if (isFinishing) {
FFI.closeAllSessions()
}
mainService?.let { mainService?.let {
unbindService(serviceConnection) unbindService(serviceConnection)
} }
@@ -257,6 +394,242 @@ class MainActivity : FlutterActivity() {
result.success(false) result.success(false)
} }
} }
PICK_IMPORT_FILES -> {
if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ImportFiles(result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
},
REQ_IMPORT_FILES
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
IMPORT_FILE -> {
val arguments = call.arguments as? Map<*, *>
val uri = (arguments?.get("uri") as? String)?.let {
runCatching { Uri.parse(it) }.getOrNull()
}
val path = arguments?.get("path") as? String
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
val destination = path?.let { canonicalAppScopedFile(it) }
if (uri?.scheme != "content") {
result.error("invalid_uri", "The selected document URI is invalid", null)
} else if (destination == null ||
destination.isDirectory ||
destination.parentFile?.isDirectory != true) {
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
} else {
thread {
var temporary: File? = null
var reservedDestination = false
var errorCode = "import_failed"
try {
val temporaryFile = File.createTempFile(
".rustdesk-import-",
".tmp",
destination.parentFile
)
temporary = temporaryFile
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(temporaryFile).use { output ->
input.copyTo(output)
}
} ?: throw IllegalStateException("Unable to open the selected document")
if (!overwrite) {
reservedDestination = destination.createNewFile()
if (!reservedDestination) {
throw IllegalStateException("The destination already exists")
}
}
if (!temporaryFile.renameTo(destination)) {
if (reservedDestination) {
destination.delete()
}
errorCode = "rename_failed"
throw IllegalStateException("Unable to replace the destination")
}
runOnUiThread { result.success(true) }
} catch (e: Exception) {
Log.e(logTag, "Failed to import file", e)
runOnUiThread {
result.error(errorCode, e.message, null)
}
} finally {
temporary?.delete()
}
}
}
}
EXPORT_FILE -> {
val path = (call.arguments as? Map<*, *>)?.get("path") as? String
val source = path?.let { canonicalExportSource(it) }
if (source?.isFile != true) {
result.error("invalid_source", "The file is outside app-scoped storage", null)
} else if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(source.extension.lowercase())
?: "application/octet-stream"
pendingPicker = PendingPicker.ExportFile(source, result)
try {
startActivityForResult(
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, source.name)
},
REQ_EXPORT_FILE
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
PICK_IMPORT_DIRECTORY -> {
if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ImportDirectory(result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(Intent.EXTRA_TITLE, "Select the folder to import")
},
REQ_IMPORT_DIRECTORY
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
IMPORT_DIRECTORY -> {
val arguments = call.arguments as? Map<*, *>
val uri = (arguments?.get("uri") as? String)?.let {
runCatching { Uri.parse(it) }.getOrNull()
}
val path = arguments?.get("path") as? String
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
val destination = path?.let { canonicalAppScopedFile(it) }
if (uri?.scheme != "content") {
result.error("invalid_uri", "The selected document URI is invalid", null)
} else if (destination == null ||
destination.parentFile?.isDirectory != true ||
(destination.exists() && !destination.isDirectory)) {
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
} else {
thread {
var temporary: File? = null
var backup: File? = null
val ok = try {
val parent = destination.parentFile
?: throw IllegalStateException("The destination has no parent")
temporary = File.createTempFile(
".rustdesk-import-dir-",
".tmp",
parent
).also {
if (!it.delete() || !it.mkdir()) {
throw IllegalStateException("Unable to create a temporary folder")
}
}
if (!copyDocumentTreeToFile(uri, temporary!!)) {
throw IllegalStateException("Unable to read all folder contents")
}
if (destination.exists()) {
if (!overwrite) {
throw IllegalStateException("The destination already exists")
}
val backupFile = File.createTempFile(
".rustdesk-import-backup-",
".tmp",
parent
)
if (!backupFile.delete()) {
throw IllegalStateException("Unable to prepare the destination backup")
}
backup = backupFile
if (!destination.renameTo(backupFile)) {
throw IllegalStateException("Unable to replace the destination")
}
}
if (!temporary!!.renameTo(destination)) {
val destinationBackup = backup
if (destinationBackup != null &&
!destinationBackup.renameTo(destination)
) {
throw IllegalStateException(
"Unable to move the imported folder and restore " +
"the destination from $destinationBackup"
)
}
throw IllegalStateException("Unable to move the imported folder")
}
temporary = null
val destinationBackup = backup
if (destinationBackup != null &&
!destinationBackup.deleteRecursively()
) {
throw IllegalStateException(
"Unable to remove the destination backup: $destinationBackup"
)
}
backup = null
true
} catch (e: Exception) {
Log.e(logTag, "Failed to import directory", e)
false
} finally {
temporary?.deleteRecursively()
}
runOnUiThread { result.success(ok) }
}
}
}
EXPORT_FILES -> {
val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*>
if (paths.isNullOrEmpty()) {
result.error("invalid_source", "The selected files are outside app-scoped storage", null)
} else {
val sources = paths.mapNotNull {
(it as? String)?.let(::canonicalExportSource)
}
val rejected = paths.size - sources.size
if (sources.isEmpty()) {
result.success(mapOf("exported" to 0, "failed" to rejected))
} else if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ExportFiles(sources, rejected, result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(Intent.EXTRA_TITLE, "Select the destination folder")
},
REQ_EXPORT_FILES
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
}
GET_VALUE -> { GET_VALUE -> {
if (call.arguments is String) { if (call.arguments is String) {
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) { if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
@@ -281,6 +654,228 @@ class MainActivity : FlutterActivity() {
} }
} }
private fun canonicalAppScopedFile(path: String): File? {
val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull {
runCatching { it.canonicalFile }.getOrNull()
}
return file.takeIf { candidate ->
allowedRoots.any { root ->
candidate == root || candidate.path.startsWith(root.path + File.separator)
}
}
}
private fun canonicalExportSource(path: String): File? {
val original = File(path).absoluteFile
val canonical = canonicalAppScopedFile(path) ?: return null
return canonical.takeIf {
original.path == canonical.path && (canonical.isFile || canonical.isDirectory)
}
}
private fun snapshotExportSource(source: File): ExportSource? {
val safeSource = canonicalExportSource(source.path) ?: return null
if (safeSource.isFile) return ExportSource(safeSource, null)
val sourceChildren = safeSource.listFiles() ?: return null
val children = ArrayList<ExportSource>(sourceChildren.size)
for (child in sourceChildren) {
val snapshot = snapshotExportSource(child) ?: return null
children.add(snapshot)
}
return ExportSource(safeSource, children)
}
private fun copyExportSourceToTree(
treeUri: Uri,
parentDocId: String,
source: ExportSource
): Boolean {
val children = source.children
return if (children == null) {
copyFileToTree(treeUri, parentDocId, source.file)
} else {
copyDirToTree(treeUri, parentDocId, source)
}
}
private fun treeDisplayName(treeUri: Uri): String? {
return try {
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
contentResolver.query(
docUri,
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null,
null,
null
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
} catch (e: Exception) {
Log.w(logTag, "Failed to read selected folder name", e)
null
}
}
private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean {
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
return copyChildrenToFile(treeUri, rootDocId, destinationDir)
}
private fun copyChildrenToFile(
treeUri: Uri,
parentDocId: String,
destinationDir: File
): Boolean {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
var ok = true
val destinationNames = HashSet<String>()
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
?: return false
cursor.use {
while (cursor.moveToNext()) {
val docId = cursor.getString(0)
val name = cursor.getString(1)
val mime = cursor.getString(2)
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
if (name != null && !destinationNames.add(name)) {
ok = false
continue
}
val destination = safeDestinationChild(destinationDir, name)
if (destination == null || destination.exists()) {
ok = false
continue
}
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
if (!destination.mkdirs() && !destination.isDirectory) {
ok = false
continue
}
if (!copyChildrenToFile(treeUri, docId, destination)) {
ok = false
}
} else if (!copyDocumentToFile(docUri, destination)) {
ok = false
}
}
}
return ok
}
private fun safeDestinationChild(destinationDir: File, name: String?): File? {
if (name.isNullOrEmpty() || name == "." || name == ".." ||
name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) {
return null
}
val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null
val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null
return child.takeIf { it.path.startsWith(parent.path + File.separator) }
}
private fun copyDocumentToFile(uri: Uri, destination: File): Boolean {
return try {
destination.parentFile?.mkdirs()
if (destination.exists() && !destination.delete()) {
return false
}
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destination).use { output -> input.copyTo(output) }
} != null
} catch (e: Exception) {
Log.e(logTag, "Failed to copy document to $destination", e)
false
}
}
private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean {
val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false
return try {
val mime = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(safeSource.extension.lowercase())
?: "application/octet-stream"
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
val docUri = DocumentsContract.createDocument(
contentResolver,
parentUri,
mime,
safeSource.name
) ?: return false
contentResolver.openOutputStream(docUri, "wt")?.use { output ->
FileInputStream(safeSource).use { input -> input.copyTo(output) }
} ?: return false
true
} catch (e: Exception) {
Log.e(logTag, "Failed to export file $safeSource", e)
false
}
}
private fun copyDirToTree(
treeUri: Uri,
parentDocId: String,
source: ExportSource
): Boolean {
val children = source.children ?: return false
val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory }
?: return false
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name)
if (dirDocId == null) {
dirDocId = try {
DocumentsContract.createDocument(
contentResolver,
parentUri,
DocumentsContract.Document.MIME_TYPE_DIR,
safeSource.name
)?.let { DocumentsContract.getDocumentId(it) }
} catch (e: Exception) {
Log.e(logTag, "Failed to create folder ${safeSource.name}", e)
null
}
}
if (dirDocId == null) return false
var ok = true
children.forEach { child ->
val childOk = copyExportSourceToTree(treeUri, dirDocId, child)
if (!childOk) ok = false
}
return ok
}
private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
?: throw IllegalStateException("Unable to query destination folder")
cursor.use {
while (cursor.moveToNext()) {
if (cursor.getString(1) == name &&
cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
) {
return cursor.getString(0)
}
}
}
return null
}
private val childColumns = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
private fun displayName(uri: Uri): String? {
return try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
} catch (e: Exception) {
Log.w(logTag, "Failed to read selected document name", e)
null
}
}
private fun setCodecInfo() { private fun setCodecInfo() {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val codecs = codecList.codecInfos val codecs = codecList.codecInfos

View File

@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.graphics.Color import android.graphics.Color
@@ -150,7 +151,7 @@ class MainService : Service() {
if (incomingVoiceCall) { if (incomingVoiceCall) {
voiceCallRequestNotification(id, "Voice Call Request", username, peerId) voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
} else { } else {
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) { if (!switchOutVoiceCall()) {
Log.e(logTag, "switchOutVoiceCall fail") Log.e(logTag, "switchOutVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error", "type" to "custom-nook-nocancel-hasclose-error",
@@ -159,7 +160,7 @@ class MainService : Service() {
} }
} }
} else { } else {
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) { if (!switchToVoiceCall()) {
Log.e(logTag, "switchToVoiceCall fail") Log.e(logTag, "switchToVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error", "type" to "custom-nook-nocancel-hasclose-error",
@@ -214,6 +215,19 @@ class MainService : Service() {
// video // video
private var mediaProjection: MediaProjection? = null private var mediaProjection: MediaProjection? = null
private var mediaProjectionCallback: MediaProjection.Callback? = null
private var captureRestartPending = false
private var captureRestartInVoiceCall = false
private val mediaProjectionResultReceiver =
object : ResultReceiver(Handler(Looper.getMainLooper())) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == RES_FAILED) {
cancelMediaProjectionRecovery()
}
}
}
private var mediaProjectionForegroundService = false
private var microphoneForegroundService = false
private var surface: Surface? = null private var surface: Surface? = null
private val sendVP9Thread = Executors.newSingleThreadExecutor() private val sendVP9Thread = Executors.newSingleThreadExecutor()
private var videoEncoder: MediaCodec? = null private var videoEncoder: MediaCodec? = null
@@ -243,7 +257,9 @@ class MainService : Service() {
// keep the config dir same with flutter // keep the config dir same with flutter
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE) val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: "" val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
FFI.startServer(configPath, "") val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
?: applicationContext.filesDir.absolutePath
FFI.startServer(configPath, homePath, "")
createForegroundNotification() createForegroundNotification()
} }
@@ -254,6 +270,16 @@ class MainService : Service() {
super.onDestroy() super.onDestroy()
} }
// Swiping the app away from recents destroys the UI but this service keeps
// the process alive, so outgoing sessions would stay connected with no way
// to close them. Incoming connections are unaffected: the service keeps
// running so the device stays reachable.
override fun onTaskRemoved(rootIntent: Intent?) {
Log.d(logTag, "onTaskRemoved, closing outgoing sessions")
FFI.closeAllSessions()
super.onTaskRemoved(rootIntent)
}
private var isHalfScale: Boolean? = null; private var isHalfScale: Boolean? = null;
private fun updateScreenInfo(orientation: Int) { private fun updateScreenInfo(orientation: Int) {
var w: Int var w: Int
@@ -327,8 +353,6 @@ class MainService : Service() {
Log.d("whichService", "this service: ${Thread.currentThread()}") Log.d("whichService", "this service: ${Thread.currentThread()}")
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) { if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
createForegroundNotification()
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) { if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
FFI.startService() FFI.startService()
} }
@@ -337,10 +361,7 @@ class MainService : Service() {
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let { intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
mediaProjection = replaceMediaProjection(mediaProjectionManager, it)
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
checkMediaPermission()
_isReady = true
} ?: let { } ?: let {
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection") Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
requestMediaProjection() requestMediaProjection()
@@ -354,14 +375,23 @@ class MainService : Service() {
updateScreenInfo(newConfig.orientation) updateScreenInfo(newConfig.orientation)
} }
private fun requestMediaProjection() { private fun requestMediaProjection(recovery: Boolean = false) {
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply { val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
action = ACT_REQUEST_MEDIA_PROJECTION action = ACT_REQUEST_MEDIA_PROJECTION
flags = Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (recovery) {
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
}
} }
startActivity(intent) startActivity(intent)
} }
@Synchronized
private fun cancelMediaProjectionRecovery() {
captureRestartPending = false
captureRestartInVoiceCall = false
}
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
private fun createSurface(): Surface? { private fun createSurface(): Surface? {
return if (useVP9) { return if (useVP9) {
@@ -395,15 +425,149 @@ class MainService : Service() {
} }
} }
fun onVoiceCallStarted(): Boolean { private fun releaseMediaProjection() {
return audioRecordHandle.onVoiceCallStarted(mediaProjection) val projection = mediaProjection
val callback = mediaProjectionCallback
mediaProjection = null
mediaProjectionCallback = null
if (projection != null && callback != null) {
projection.unregisterCallback(callback)
}
projection?.stop()
} }
@Synchronized
private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) {
if (mediaProjection !== stoppedProjection) {
return
}
Log.d(logTag, "MediaProjection stopped")
setMediaProjectionForegroundService(false)
stopCapture()
virtualDisplay?.release()
virtualDisplay = null
mediaProjection = null
mediaProjectionCallback = null
_isReady = false
checkMediaPermission()
}
@Synchronized
private fun replaceMediaProjection(
mediaProjectionManager: MediaProjectionManager,
resultIntent: Intent,
) {
val wasCapturing = isStart
val restartCapture = wasCapturing || captureRestartPending
val restartInVoiceCall = if (wasCapturing) {
audioRecordHandle.isVoiceCallActive()
} else {
captureRestartInVoiceCall
}
val hadProjection = mediaProjection != null
if (!setMediaProjectionForegroundService(true)) {
if (!hadProjection) {
cancelMediaProjectionRecovery()
_isReady = false
checkMediaPermission()
}
return
}
val projection =
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent)
if (projection == null) {
if (!hadProjection) {
cancelMediaProjectionRecovery()
_isReady = false
setMediaProjectionForegroundService(false)
checkMediaPermission()
}
return
}
if (wasCapturing) {
stopCapture()
}
captureRestartPending = restartCapture
virtualDisplay?.release()
virtualDisplay = null
releaseMediaProjection()
val callback = object : MediaProjection.Callback() {
override fun onStop() {
handleMediaProjectionStopped(projection)
}
}
projection.registerCallback(callback, Handler(Looper.getMainLooper()))
mediaProjection = projection
mediaProjectionCallback = callback
_isReady = true
checkMediaPermission()
if (restartCapture) {
captureRestartPending = false
startCapture(restartInVoiceCall)
}
}
@Synchronized
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
if (!setMicrophoneForegroundService(true)) {
return false
}
if (startAudio()) {
return true
}
setMicrophoneForegroundService(false)
return false
}
@Synchronized
private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean {
val stopped = stopAudio()
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
return stopped && foregroundServiceUpdated
}
@Synchronized
private fun switchToVoiceCall(): Boolean {
if (captureRestartPending) {
captureRestartInVoiceCall = true
}
return startMicrophoneCapture {
audioRecordHandle.switchToVoiceCall(mediaProjection)
}
}
@Synchronized
private fun switchOutVoiceCall(): Boolean {
captureRestartInVoiceCall = false
val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection)
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
return switched && foregroundServiceUpdated
}
@Synchronized
fun onVoiceCallStarted(): Boolean {
if (captureRestartPending) {
captureRestartInVoiceCall = true
}
return startMicrophoneCapture {
audioRecordHandle.onVoiceCallStarted(mediaProjection)
}
}
@Synchronized
fun onVoiceCallClosed(): Boolean { fun onVoiceCallClosed(): Boolean {
return audioRecordHandle.onVoiceCallClosed(mediaProjection) captureRestartInVoiceCall = false
return stopMicrophoneCapture {
audioRecordHandle.onVoiceCallClosed(mediaProjection)
}
} }
fun startCapture(): Boolean { fun startCapture(): Boolean {
return startCapture(false)
}
@Synchronized
private fun startCapture(inVoiceCall: Boolean): Boolean {
if (isStart) { if (isStart) {
return true return true
} }
@@ -411,25 +575,35 @@ class MainService : Service() {
Log.w(logTag, "startCapture fail,mediaProjection is null") Log.w(logTag, "startCapture fail,mediaProjection is null")
return false return false
} }
captureRestartInVoiceCall = inVoiceCall
updateScreenInfo(resources.configuration.orientation) updateScreenInfo(resources.configuration.orientation)
Log.d(logTag, "Start Capture") Log.d(logTag, "Start Capture")
surface = createSurface() surface = createSurface()
if (useVP9) { val videoStarted = if (useVP9) {
startVP9VideoRecorder(mediaProjection!!) startVP9VideoRecorder(mediaProjection!!)
} else { } else {
startRawVideoRecorder(mediaProjection!!) startRawVideoRecorder(mediaProjection!!)
} }
if (!videoStarted) {
if (!captureRestartPending) {
captureRestartInVoiceCall = false
}
releaseFailedVideoCapture()
return false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) { val audioStarted = if (inVoiceCall) {
Log.d(logTag, "createAudioRecorder fail") switchToVoiceCall()
} else { } else {
Log.d(logTag, "audio recorder start") audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
audioRecordHandle.startAudioRecorder() audioRecordHandle.startAudioRecorder()
} }
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
} }
captureRestartInVoiceCall = false
checkMediaPermission() checkMediaPermission()
_isStart = true _isStart = true
FFI.setFrameRawEnable("video",true) FFI.setFrameRawEnable("video",true)
@@ -437,9 +611,24 @@ class MainService : Service() {
return true return true
} }
private fun releaseFailedVideoCapture() {
imageReader?.close()
imageReader = null
videoEncoder?.let {
it.signalEndOfInputStream()
it.stop()
it.release()
}
videoEncoder = null
surface?.release()
surface = null
}
@Synchronized @Synchronized
fun stopCapture() { fun stopCapture() {
Log.d(logTag, "Stop Capture") Log.d(logTag, "Stop Capture")
captureRestartPending = false
captureRestartInVoiceCall = false
FFI.setFrameRawEnable("video",false) FFI.setFrameRawEnable("video",false)
_isStart = false _isStart = false
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart) MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
@@ -470,8 +659,11 @@ class MainService : Service() {
surface?.release() surface?.release()
// release audio // release audio
_isAudioStart = false stopMicrophoneCapture {
audioRecordHandle.tryReleaseAudio() _isAudioStart = false
audioRecordHandle.tryReleaseAudio()
true
}
} }
fun destroy() { fun destroy() {
@@ -486,7 +678,9 @@ class MainService : Service() {
virtualDisplay = null virtualDisplay = null
} }
mediaProjection = null releaseMediaProjection()
mediaProjectionForegroundService = false
microphoneForegroundService = false
checkMediaPermission() checkMediaPermission()
stopForeground(true) stopForeground(true)
stopService(Intent(this, FloatingWindowService::class.java)) stopService(Intent(this, FloatingWindowService::class.java))
@@ -509,49 +703,70 @@ class MainService : Service() {
return isReady return isReady
} }
private fun startRawVideoRecorder(mp: MediaProjection) { private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO") Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
if (surface == null) { val captureSurface = surface
if (captureSurface == null) {
Log.d(logTag, "startRawVideoRecorder failed,surface is null") Log.d(logTag, "startRawVideoRecorder failed,surface is null")
return return false
} }
createOrSetVirtualDisplay(mp, surface!!) return createOrSetVirtualDisplay(mp, captureSurface)
} }
private fun startVP9VideoRecorder(mp: MediaProjection) { private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
createMediaCodec() createMediaCodec()
videoEncoder?.let { val encoder = videoEncoder ?: return false
surface = it.createInputSurface() val inputSurface = encoder.createInputSurface()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { surface = inputSurface
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
} inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
it.setCallback(cb)
it.start()
createOrSetVirtualDisplay(mp, surface!!)
} }
encoder.setCallback(cb)
encoder.start()
return createOrSetVirtualDisplay(mp, inputSurface)
} }
// https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250 // https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250
// Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection. // Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) { private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
try { return try {
virtualDisplay?.let { val existingDisplay = virtualDisplay
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi) if (existingDisplay != null) {
it.setSurface(s) existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
} ?: let { existingDisplay.setSurface(s)
virtualDisplay = mp.createVirtualDisplay( true
} else {
val display = mp.createVirtualDisplay(
"RustDeskVD", "RustDeskVD",
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
s, null, null s, null, null
) )
if (display == null) {
Log.e(logTag, "createOrSetVirtualDisplay failed")
handleVirtualDisplayFailure()
} else {
virtualDisplay = display
true
}
} }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation"); Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
// This initiates a prompt dialog for the user to confirm screen projection. handleVirtualDisplayFailure()
requestMediaProjection()
} }
} }
private fun handleVirtualDisplayFailure(): Boolean {
captureRestartPending = true
virtualDisplay?.release()
virtualDisplay = null
releaseMediaProjection()
setMediaProjectionForegroundService(false)
_isReady = false
checkMediaPermission()
requestMediaProjection(true)
return false
}
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() { private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {} override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {} override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
@@ -642,7 +857,63 @@ class MainService : Service() {
.setColor(ContextCompat.getColor(this, R.color.primary)) .setColor(ContextCompat.getColor(this, R.color.primary))
.setWhen(System.currentTimeMillis()) .setWhen(System.currentTimeMillis())
.build() .build()
startForeground(DEFAULT_NOTIFY_ID, notification) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType())
} else {
startForeground(DEFAULT_NOTIFY_ID, notification)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun foregroundServiceType(): Int {
var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Keep a valid FGS type while the unattended host is idle and no capture type is active.
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
}
if (mediaProjectionForegroundService) {
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) {
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
return serviceType
}
private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean {
return updateForegroundServiceTypes(enabled, microphoneForegroundService)
}
private fun setMicrophoneForegroundService(enabled: Boolean): Boolean {
return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled)
}
private fun updateForegroundServiceTypes(
mediaProjectionEnabled: Boolean,
microphoneEnabled: Boolean,
): Boolean {
if (mediaProjectionForegroundService == mediaProjectionEnabled &&
microphoneForegroundService == microphoneEnabled) {
return true
}
val previousMediaProjection = mediaProjectionForegroundService
val previousMicrophone = microphoneForegroundService
mediaProjectionForegroundService = mediaProjectionEnabled
microphoneForegroundService = microphoneEnabled
return try {
createForegroundNotification()
true
} catch (error: SecurityException) {
mediaProjectionForegroundService = previousMediaProjection
microphoneForegroundService = previousMicrophone
Log.e(logTag, "Failed to update foreground service types", error)
false
} catch (error: IllegalStateException) {
mediaProjectionForegroundService = previousMediaProjection
microphoneForegroundService = previousMicrophone
Log.e(logTag, "Failed to update foreground service types", error)
false
}
} }
private fun loginRequestNotification( private fun loginRequestNotification(

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.ResultReceiver
import android.util.Log import android.util.Log
class PermissionRequestTransparentActivity: Activity() { class PermissionRequestTransparentActivity: Activity() {
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
if (resultCode == RESULT_OK && data != null) { if (resultCode == RESULT_OK && data != null) {
launchService(data) launchService(data)
} else { } else {
setResult(RES_FAILED) val resultReceiver =
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
if (resultReceiver != null) {
resultReceiver.send(RES_FAILED, null)
} else {
setResult(RES_FAILED)
}
} }
} }
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
} }
} }
} }

View File

@@ -33,11 +33,16 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER
const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY" const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT" const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT" const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY" const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
// Activity requestCode // Activity requestCode
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101 const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
const val REQ_REQUEST_MEDIA_PROJECTION = 201 const val REQ_REQUEST_MEDIA_PROJECTION = 201
const val REQ_EXPORT_FILE = 301
const val REQ_IMPORT_FILES = 302
const val REQ_IMPORT_DIRECTORY = 303
const val REQ_EXPORT_FILES = 304
// Activity responseCode // Activity responseCode
const val RES_FAILED = -100 const val RES_FAILED = -100
@@ -47,6 +52,12 @@ const val START_ACTION = "start_action"
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt" const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt" const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir" const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
const val PICK_IMPORT_FILES = "pick_import_files"
const val IMPORT_FILE = "import_file"
const val EXPORT_FILE = "export_file"
const val PICK_IMPORT_DIRECTORY = "pick_import_directory"
const val IMPORT_DIRECTORY = "import_directory"
const val EXPORT_FILES = "export_files"
const val GET_VALUE = "get_value" const val GET_VALUE = "get_value"
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL" const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
@@ -154,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
fun translate(input: String): String { fun translate(input: String): String {
Log.d("common", "translate:$LOCAL_NAME") Log.d("common", "translate:$LOCAL_NAME")
return FFI.translateLocale(LOCAL_NAME, input) return FFI.translateLocale(LOCAL_NAME, input)
} }

View File

@@ -15,12 +15,13 @@ object FFI {
external fun init(ctx: Context) external fun init(ctx: Context)
external fun onAppStart(ctx: Context) external fun onAppStart(ctx: Context)
external fun setClipboardManager(clipboardManager: RdClipboardManager) external fun setClipboardManager(clipboardManager: RdClipboardManager)
external fun startServer(app_dir: String, custom_client_config: String) external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
external fun startService() external fun startService()
external fun onVideoFrameUpdate(buf: ByteBuffer) external fun onVideoFrameUpdate(buf: ByteBuffer)
external fun onAudioFrameUpdate(buf: ByteBuffer) external fun onAudioFrameUpdate(buf: ByteBuffer)
external fun translateLocale(localeName: String, input: String): String external fun translateLocale(localeName: String, input: String): String
external fun refreshScreen() external fun refreshScreen()
external fun closeAllSessions()
external fun setFrameRawEnable(name: String, value: Boolean) external fun setFrameRawEnable(name: String, value: Boolean)
external fun setCodecInfo(info: String) external fun setCodecInfo(info: String)
external fun getLocalOption(key: String): String external fun getLocalOption(key: String): String

View File

@@ -1,4 +1,5 @@
<resources> <resources>
<string name="app_name">RustDesk</string> <string name="app_name">RustDesk</string>
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string> <string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
</resources> </resources>

View File

@@ -1,3 +1,29 @@
def legacyPluginNamespaces = [
external_path: 'com.pinciat.external_path',
flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility',
qr_code_scanner: 'net.touchcapture.qr.flutterqr',
sqflite: 'com.tekartik.sqflite',
uni_links: 'name.avioli.unilinks',
]
def java8JvmTarget = JavaVersion.VERSION_1_8.toString()
def java8KotlinJvmTargets = [
app: java8JvmTarget,
external_path: java8JvmTarget,
qr_code_scanner: java8JvmTarget,
]
def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget ->
project.plugins.withId('kotlin-android') {
project.tasks.configureEach { task ->
if (!task.hasProperty('kotlinOptions')) {
return
}
task.kotlinOptions.jvmTarget = kotlinJvmTarget
}
}
}
allprojects { allprojects {
repositories { repositories {
google() google()
@@ -9,6 +35,16 @@ allprojects {
rootProject.buildDir = '../build' rootProject.buildDir = '../build'
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.buildDir = "${rootProject.buildDir}/${project.name}"
def legacyNamespace = legacyPluginNamespaces[project.name]
if (legacyNamespace != null) {
project.plugins.withId('com.android.library') {
project.android.namespace = legacyNamespace
}
}
def kotlinJvmTarget = java8KotlinJvmTargets[project.name]
if (kotlinJvmTarget != null) {
configureKotlinJvmTarget(project, kotlinJvmTarget)
}
} }
subprojects { subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.1" apply false id "com.android.application" version "8.10.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.21" apply false id "org.jetbrains.kotlin.android" version "2.1.21" apply false
} }

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn</string>
<key>API_KEY</key>
<string>AIzaSyCf57HjCwSokt91CqFI0Mwf8D--ek0jvfc</string>
<key>GCM_SENDER_ID</key>
<string>768133699366</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.carriez.flutterHbb</string>
<key>PROJECT_ID</key>
<string>rustdesk</string>
<key>STORAGE_BUCKET</key>
<string>rustdesk.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:768133699366:ios:c33078a6181b9d507993e7</string>
<key>DATABASE_URL</key>
<string>https://rustdesk.firebaseio.com</string>
</dict>
</plist>

View File

@@ -84,8 +84,6 @@ const double _kPositionEpsilon = 1e-6;
bool get isMainDesktopWindow => bool get isMainDesktopWindow =>
desktopType == DesktopType.main || desktopType == DesktopType.cm; desktopType == DesktopType.main || desktopType == DesktopType.cm;
String get screenInfo => screenInfo_;
/// Check if the app is running with single view mode. /// Check if the app is running with single view mode.
bool isSingleViewApp() { bool isSingleViewApp() {
return desktopType == DesktopType.cm; return desktopType == DesktopType.cm;
@@ -1521,13 +1519,6 @@ class AndroidPermissionManager {
static Timer? _timer; static Timer? _timer;
static var _current = ""; static var _current = "";
static bool isWaitingFile() {
if (_completer != null) {
return !_completer!.isCompleted && _current == kManageExternalStorage;
}
return false;
}
static Future<bool> check(String type) { static Future<bool> check(String type) {
if (isDesktop || isWeb) { if (isDesktop || isWeb) {
return Future.value(true); return Future.value(true);
@@ -1642,7 +1633,8 @@ String bool2option(String option, bool b) {
String res; String res;
if (option.startsWith('enable-') && if (option.startsWith('enable-') &&
option != kOptionEnableUdpPunch && option != kOptionEnableUdpPunch &&
option != kOptionEnableIpv6Punch) { option != kOptionEnableIpv6Punch &&
option != kOptionEnableWebrtc) {
res = b ? defaultOptionYes : 'N'; res = b ? defaultOptionYes : 'N';
} else if (option.startsWith('allow-') || } else if (option.startsWith('allow-') ||
option == kOptionStopService || option == kOptionStopService ||
@@ -2636,13 +2628,6 @@ connect(BuildContext context, String id,
} }
} else { } else {
if (isFileTransfer) { if (isFileTransfer) {
if (isAndroid) {
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
return;
}
}
}
if (isWeb) { if (isWeb) {
Navigator.push( Navigator.push(
context, context,
@@ -3124,6 +3109,15 @@ void onCopyFingerprint(String value) {
} }
} }
void onCopyId(String value) {
if (value.isNotEmpty) {
Clipboard.setData(ClipboardData(text: value));
showToast('$value\n${translate("Copied")}');
} else {
showToast(translate("Invalid ID"));
}
}
Future<bool> callMainCheckSuperUserPermission() async { Future<bool> callMainCheckSuperUserPermission() async {
bool checked = await bind.mainCheckSuperUserPermission(); bool checked = await bind.mainCheckSuperUserPermission();
if (isMacOS) { if (isMacOS) {
@@ -4004,6 +3998,11 @@ bool whitelistNotEmpty() {
return v != '' && v != ','; return v != '' && v != ',';
} }
bool idWhitelistNotEmpty() {
final v = bind.mainGetOptionSync(key: kOptionIdWhitelist);
return v != '' && v != ',';
}
// `setMovable()` is only supported on macOS. // `setMovable()` is only supported on macOS.
// //
// On macOS, the window can be dragged by the tab bar by default. // On macOS, the window can be dragged by the tab bar by default.
@@ -4034,7 +4033,8 @@ Widget netWorkErrorWidget() {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text(translate("network_error_tip")), if (!gFFI.userModel.networkErrorFromServer.value)
Text(translate("network_error_tip")),
ElevatedButton( ElevatedButton(
onPressed: gFFI.userModel.refreshCurrentUser, onPressed: gFFI.userModel.refreshCurrentUser,
child: Text(translate("Retry"))) child: Text(translate("Retry")))

View File

@@ -205,6 +205,10 @@ void changeWhiteList({Function()? callback}) async {
const SizedBox( const SizedBox(
height: 8.0, height: 8.0,
), ),
Text(translate("whitelist_cidr_tip")),
const SizedBox(
height: 8.0,
),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -282,6 +286,111 @@ void changeWhiteList({Function()? callback}) async {
}); });
} }
void changeIdWhiteList({Function()? callback}) async {
final curIdWhiteList = await bind.mainGetOption(key: kOptionIdWhitelist);
var newIdWhiteListField = curIdWhiteList == defaultOptionWhitelist
? ''
: curIdWhiteList.split(',').join('\n');
var controller = TextEditingController(text: newIdWhiteListField);
var msg = "";
var isInProgress = false;
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
gFFI.dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Text(translate("ID whitelisting")),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate("whitelist_sep")),
const SizedBox(
height: 8.0,
),
Text(translate("id_whitelist_wildcard_tip")),
const SizedBox(
height: 8.0,
),
Text(translate("id_whitelist_caveat_tip")),
const SizedBox(
height: 8.0,
),
Row(
children: [
Expanded(
child: TextField(
maxLines: null,
decoration: InputDecoration(
errorText: msg.isEmpty ? null : translate(msg),
),
controller: controller,
enabled: !isOptFixed,
autofocus: true)
.workaroundFreezeLinuxMint(),
),
],
),
const SizedBox(
height: 4.0,
),
// NOT use Offstage to wrap LinearProgressIndicator
if (isInProgress) const LinearProgressIndicator(),
],
),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
if (!isOptFixed)
dialogButton("Clear", onPressed: () async {
await bind.mainSetOption(
key: kOptionIdWhitelist, value: defaultOptionWhitelist);
callback?.call();
close();
}, isOutline: true),
if (!isOptFixed)
dialogButton(
"OK",
onPressed: () async {
setState(() {
msg = "";
isInProgress = true;
});
newIdWhiteListField = controller.text.trim();
var newIdWhiteList = "";
if (newIdWhiteListField.isEmpty) {
// pass
} else {
final ids = newIdWhiteListField
.trim()
.split(RegExp(r"[\s,;\n]+"))
.where((e) => e.isNotEmpty)
.toList();
// Separators are handled above; allow all other Unicode characters.
for (final id in ids) {
final hasControlCharacters = id.runes.any(
(char) => char <= 0x1f || (char >= 0x7f && char <= 0x9f));
if (hasControlCharacters) {
msg = "${translate("Invalid ID")} $id";
setState(() {
isInProgress = false;
});
return;
}
}
newIdWhiteList = ids.join(',');
}
if (newIdWhiteList.trim().isEmpty) {
newIdWhiteList = defaultOptionWhitelist;
}
await bind.mainSetOption(
key: kOptionIdWhitelist, value: newIdWhiteList);
callback?.call();
close();
},
),
],
onCancel: close,
);
});
}
Future<String> changeDirectAccessPort( Future<String> changeDirectAccessPort(
String currentIP, String currentPort) async { String currentIP, String currentPort) async {
final controller = TextEditingController(text: currentPort); final controller = TextEditingController(text: currentPort);
@@ -827,26 +936,19 @@ void enterPasswordDialog(
); );
} }
void enterUserLoginDialog( void enterUserLoginDialog(SessionID sessionId,
SessionID sessionId, OverlayDialogManager dialogManager, String osAccountDescTip) async {
OverlayDialogManager dialogManager,
String osAccountDescTip,
bool canRememberAccount) async {
await _connectDialog( await _connectDialog(
sessionId, sessionId,
dialogManager, dialogManager,
osUsernameController: TextEditingController(), osUsernameController: TextEditingController(),
osPasswordController: TextEditingController(), osPasswordController: TextEditingController(),
osAccountDescTip: osAccountDescTip, osAccountDescTip: osAccountDescTip,
canRememberAccount: canRememberAccount,
); );
} }
void enterUserLoginAndPasswordDialog( void enterUserLoginAndPasswordDialog(SessionID sessionId,
SessionID sessionId, OverlayDialogManager dialogManager, String osAccountDescTip) async {
OverlayDialogManager dialogManager,
String osAccountDescTip,
bool canRememberAccount) async {
await _connectDialog( await _connectDialog(
sessionId, sessionId,
dialogManager, dialogManager,
@@ -854,7 +956,6 @@ void enterUserLoginAndPasswordDialog(
osPasswordController: TextEditingController(), osPasswordController: TextEditingController(),
passwordController: TextEditingController(), passwordController: TextEditingController(),
osAccountDescTip: osAccountDescTip, osAccountDescTip: osAccountDescTip,
canRememberAccount: canRememberAccount,
); );
} }
@@ -865,7 +966,6 @@ _connectDialog(
TextEditingController? osPasswordController, TextEditingController? osPasswordController,
TextEditingController? passwordController, TextEditingController? passwordController,
String? osAccountDescTip, String? osAccountDescTip,
bool canRememberAccount = true,
}) async { }) async {
final errUsername = ''.obs; final errUsername = ''.obs;
var rememberPassword = false; var rememberPassword = false;
@@ -873,11 +973,6 @@ _connectDialog(
rememberPassword = rememberPassword =
await bind.sessionGetRemember(sessionId: sessionId) ?? false; await bind.sessionGetRemember(sessionId: sessionId) ?? false;
} }
var rememberAccount = false;
if (canRememberAccount && osUsernameController != null) {
rememberAccount =
await bind.sessionGetRemember(sessionId: sessionId) ?? false;
}
if (osUsernameController != null) { if (osUsernameController != null) {
osUsernameController.addListener(() { osUsernameController.addListener(() {
if (errUsername.value.isNotEmpty) { if (errUsername.value.isNotEmpty) {
@@ -905,12 +1000,6 @@ _connectDialog(
final osPassword = osPasswordController?.text.trim() ?? ''; final osPassword = osPasswordController?.text.trim() ?? '';
final password = passwordController?.text.trim() ?? ''; final password = passwordController?.text.trim() ?? '';
if (passwordController != null && password.isEmpty) return; if (passwordController != null && password.isEmpty) return;
if (rememberAccount) {
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-username', value: osUsername);
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-password', value: osPassword);
}
gFFI.login( gFFI.login(
osUsername, osUsername,
osPassword, osPassword,
@@ -987,16 +1076,6 @@ _connectDialog(
controller: osPasswordController, controller: osPasswordController,
autoFocus: false, autoFocus: false,
), ),
if (canRememberAccount)
rememberWidget(
translate('remember_account_tip'),
rememberAccount,
(v) {
if (v != null) {
setState(() => rememberAccount = v);
}
},
),
], ],
); );
} }
@@ -1433,91 +1512,6 @@ showSetOSPassword(
}); });
} }
showSetOSAccount(
SessionID sessionId,
OverlayDialogManager dialogManager,
) async {
final usernameController = TextEditingController();
final passwdController = TextEditingController();
var username =
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-username') ??
'';
var password =
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-password') ??
'';
usernameController.text = username;
passwdController.text = password;
dialogManager.show((setState, close, context) {
submit() {
final username = usernameController.text.trim();
final password = usernameController.text.trim();
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-username', value: username);
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-password', value: password);
close();
}
descWidget(String text) {
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
text,
maxLines: 3,
softWrap: true,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 16),
),
),
Container(
height: 8,
),
],
);
}
return CustomAlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.password_rounded, color: MyTheme.accent),
Text(translate('OS Account')).paddingOnly(left: 10),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
descWidget(translate("os_account_desk_tip")),
DialogTextField(
title: translate(DialogTextField.kUsernameTitle),
controller: usernameController,
prefixIcon: DialogTextField.kUsernameIcon,
errorText: null,
),
PasswordWidget(controller: passwdController),
],
),
actions: [
dialogButton(
"Cancel",
icon: Icon(Icons.close_rounded),
onPressed: close,
isOutline: true,
),
dialogButton(
"OK",
icon: Icon(Icons.done_rounded),
onPressed: submit,
),
],
onSubmit: submit,
onCancel: close,
);
});
}
Widget buildNoteTextField({ Widget buildNoteTextField({
required TextEditingController controller, required TextEditingController controller,
required VoidCallback onEscape, required VoidCallback onEscape,
@@ -1905,26 +1899,110 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async {
msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]); msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]);
} }
trackpadSpeedDialog(SessionID sessionId, FFI ffi) async { int? _validateTrackpadSpeed(String text) {
int initSpeed = ffi.inputModel.trackpadSpeed; final speed = int.tryParse(text);
if (speed == null || speed < kMinTrackpadSpeed || speed > kMaxTrackpadSpeed) {
BotToast.showText(
text:
'${translate('Invalid format')}: $kMinTrackpadSpeed-$kMaxTrackpadSpeed',
contentColor: Colors.red,
);
return null;
}
return speed;
}
Future<void> _saveTrackpadSpeed({
required SessionID sessionId,
required FFI ffi,
required int initSpeed,
required int speed,
}) async {
if (speed == initSpeed) {
return;
}
await bind.sessionSetTrackpadSpeed(sessionId: sessionId, value: speed);
await ffi.inputModel.updateTrackpadSpeed();
}
void _showTrackpadSpeedSaveError(Object error, StackTrace stackTrace) {
debugPrint('Failed to save trackpad speed: $error');
debugPrintStack(stackTrace: stackTrace);
BotToast.showText(
text: translate('Failed'),
contentColor: Colors.red,
);
}
List<Widget> _trackpadSpeedDialogActions({
required bool isSubmitting,
required VoidCallback close,
required VoidCallback submit,
}) {
return [
dialogButton(
'Cancel',
icon: Icon(Icons.close_rounded),
onPressed: isSubmitting ? null : close,
isOutline: true,
),
dialogButton(
'OK',
icon: Icon(Icons.done_rounded),
onPressed: isSubmitting ? null : submit,
),
];
}
void trackpadSpeedDialog(SessionID sessionId, FFI ffi) {
final initSpeed = ffi.inputModel.trackpadSpeed;
final curSpeed = SimpleWrapper(initSpeed); final curSpeed = SimpleWrapper(initSpeed);
final btnClose = dialogButton('Close', onPressed: () async { var speedText = initSpeed.toString();
if (curSpeed.value <= kMaxTrackpadSpeed && var isSubmitting = false;
curSpeed.value >= kMinTrackpadSpeed && ffi.dialogManager.show((setState, close, context) {
curSpeed.value != initSpeed) { Future<void> submit([String? submittedText]) async {
await bind.sessionSetTrackpadSpeed( if (isSubmitting) {
sessionId: sessionId, value: curSpeed.value); return;
await ffi.inputModel.updateTrackpadSpeed(); }
speedText = submittedText ?? speedText;
final speed = _validateTrackpadSpeed(speedText);
if (speed == null) {
return;
}
setState(() => isSubmitting = true);
try {
await _saveTrackpadSpeed(
sessionId: sessionId,
ffi: ffi,
initSpeed: initSpeed,
speed: speed,
);
close();
} catch (error, stackTrace) {
_showTrackpadSpeedSaveError(error, stackTrace);
setState(() => isSubmitting = false);
}
} }
ffi.dialogManager.dismissAll();
}); return CustomAlertDialog(
msgBoxCommon( title: Text(
ffi.dialogManager, translate('Trackpad speed'),
'Trackpad speed', style: TextStyle(fontSize: 21),
TrackpadSpeedWidget(
value: curSpeed,
), ),
[btnClose]); content: TrackpadSpeedWidget(
value: curSpeed,
onTextChanged: (text) => speedText = text,
onTextSubmitted: submit,
),
actions: _trackpadSpeedDialogActions(
isSubmitting: isSubmitting,
close: close,
submit: submit,
),
onSubmit: isSubmitting ? null : submit,
onCancel: isSubmitting ? null : close,
);
});
} }
void deleteConfirmDialog(Function onSubmit, String title) async { void deleteConfirmDialog(Function onSubmit, String title) async {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart'; import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/user_model.dart';
@@ -11,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../common.dart'; import '../../common.dart';
import './dialog.dart'; import './dialog.dart';
import './oidc_auth_status.dart';
const kOpSvgList = [ const kOpSvgList = [
'github', 'github',
@@ -23,6 +25,8 @@ const kOpSvgList = [
'auth0', 'auth0',
'microsoft' 'microsoft'
]; ];
const _requestingAccountAuth = 'Requesting account auth';
const _waitingAccountAuth = 'Waiting account auth';
class _OidcProviderBranding { class _OidcProviderBranding {
final String label; final String label;
@@ -90,6 +94,7 @@ class ButtonOP extends StatelessWidget {
final Color primaryColor; final Color primaryColor;
final double height; final double height;
final Function() onTap; final Function() onTap;
final bool Function() canStartAuth;
const ButtonOP({ const ButtonOP({
Key? key, Key? key,
@@ -99,6 +104,7 @@ class ButtonOP extends StatelessWidget {
required this.primaryColor, required this.primaryColor,
required this.height, required this.height,
required this.onTap, required this.onTap,
required this.canStartAuth,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -111,11 +117,10 @@ class ButtonOP extends StatelessWidget {
width: 200, width: 200,
child: Obx(() => ElevatedButton( child: Obx(() => ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: curOP.value.isEmpty || curOP.value == op backgroundColor: primaryColor,
? primaryColor
: Colors.grey,
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)), ).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null, onPressed:
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
child: Row( child: Row(
children: [ children: [
SizedBox( SizedBox(
@@ -145,15 +150,120 @@ class ConfigOP {
ConfigOP({required this.op, required this.icon}); ConfigOP({required this.op, required this.icon});
} }
class _OidcAuthController {
final RxString curOP = ''.obs;
Future<void> _pendingOperation = Future<void>.value();
int _authAttempt = 0;
bool _closed = false;
final _cancelInProgress = false.obs;
bool _isCurrent(int authAttempt, String op) {
return !_closed && authAttempt == _authAttempt && curOP.value == op;
}
Future<bool> start(String op) {
if (!canStart()) {
return Future<bool>.value(false);
}
final authAttempt = ++_authAttempt;
curOP.value = op;
// Web auth must start during the original user gesture so popups are allowed.
if (isWeb) {
return _startWeb(authAttempt, op);
}
final completer = Completer<bool>();
_pendingOperation = _pendingOperation.then((_) async {
if (!_isCurrent(authAttempt, op)) {
completer.complete(false);
return;
}
try {
await bind.mainAccountAuthCancel();
if (!_isCurrent(authAttempt, op)) {
completer.complete(false);
return;
}
await bind.mainAccountAuth(op: op, rememberMe: true);
completer.complete(_isCurrent(authAttempt, op));
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
});
return completer.future;
}
Future<bool> _startWeb(int authAttempt, String op) async {
await bind.mainAccountAuth(op: op, rememberMe: true);
return _isCurrent(authAttempt, op);
}
bool canStart() {
return !_closed && !_cancelInProgress.value;
}
Future<bool> cancelCurrent(String op) {
if (!canStart() || curOP.value != op) {
return Future<bool>.value(false);
}
final authAttempt = ++_authAttempt;
final completer = Completer<bool>();
_cancelInProgress.value = true;
_pendingOperation = _pendingOperation.then((_) async {
try {
await bind.mainAccountAuthCancel();
completer.complete(_isCurrent(authAttempt, op));
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} finally {
_cancelInProgress.value = false;
}
});
return completer.future;
}
Future<void> _cancelBackend() async {
try {
await bind.mainAccountAuthCancel();
} catch (error, stackTrace) {
debugPrint('Failed to cancel account authentication $error');
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> close() async {
if (_closed) {
return;
}
final hasActiveOidcAuth =
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
_closed = true;
_authAttempt++;
curOP.value = '';
if (hasActiveOidcAuth) {
await _cancelBackend();
}
await _pendingOperation;
if (hasActiveOidcAuth) {
await _cancelBackend();
}
}
}
class WidgetOP extends StatefulWidget { class WidgetOP extends StatefulWidget {
final ConfigOP config; final ConfigOP config;
final RxString curOP; final RxString curOP;
final Function(Map<String, dynamic>) cbLogin; final Function(Map<String, dynamic>) cbLogin;
final Future<bool> Function(String) startAuth;
final Future<bool> Function(String) cancelAuth;
final bool Function() canStartAuth;
const WidgetOP({ const WidgetOP({
Key? key, Key? key,
required this.config, required this.config,
required this.curOP, required this.curOP,
required this.cbLogin, required this.cbLogin,
required this.startAuth,
required this.cancelAuth,
required this.canStartAuth,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -164,6 +274,8 @@ class WidgetOP extends StatefulWidget {
class _WidgetOPState extends State<WidgetOP> { class _WidgetOPState extends State<WidgetOP> {
Timer? _updateTimer; Timer? _updateTimer;
bool _isAuthStatusQueryInFlight = false;
int _authAttempt = 0;
String _stateMsg = ''; String _stateMsg = '';
String _failedMsg = ''; String _failedMsg = '';
String _url = ''; String _url = '';
@@ -174,55 +286,180 @@ class _WidgetOPState extends State<WidgetOP> {
_updateTimer?.cancel(); _updateTimer?.cancel();
} }
_beginQueryState() { _beginQueryState(int authAttempt) {
_updateTimer?.cancel();
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) { _updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
_updateState(); unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
}); });
} }
_updateState() { Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
bind.mainAccountAuthResult().then((result) { if (_isAuthStatusQueryInFlight) {
if (result.isEmpty) { return;
}
_isAuthStatusQueryInFlight = true;
try {
await query();
} finally {
_isAuthStatusQueryInFlight = false;
}
}
Future<void> _launchAuthUrl(String url) async {
try {
final launched = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!launched) {
debugPrint('Failed to open OIDC authentication URL');
}
} catch (error, stackTrace) {
debugPrint(
'Failed to open OIDC authentication URL (${error.runtimeType})');
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> _copyAuthUrl(String url) async {
try {
await Clipboard.setData(ClipboardData(text: url));
showToast(
translate('Copied'),
);
} catch (error, stackTrace) {
debugPrint(
'Failed to copy OIDC authentication URL (${error.runtimeType})');
debugPrintStack(stackTrace: stackTrace);
showToast(translate('Failed'));
}
}
void _runCurrentAuthUrlAction(
int authAttempt,
String authUrl,
Future<void> Function(String) action,
) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op ||
authUrl.isEmpty ||
_url != authUrl) {
return;
}
unawaited(action(authUrl));
}
void _invalidateAuthAttempt() {
_authAttempt++;
_url = '';
}
bool _isCurrentAuthAttempt(int authAttempt) {
return mounted &&
authAttempt == _authAttempt &&
widget.curOP.value == widget.config.op;
}
Future<void> _handleAuthFailure(
int authAttempt,
Object error,
String operation,
) async {
debugPrint('Failed to $operation $error');
if (!_isCurrentAuthAttempt(authAttempt)) {
return;
}
_updateTimer?.cancel();
setState(() => _failedMsg = 'Failed');
try {
final canceled = await widget.cancelAuth(widget.config.op);
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
return;
}
} catch (cancelError, stackTrace) {
debugPrint('Failed to cancel account authentication $cancelError');
debugPrintStack(stackTrace: stackTrace);
return;
}
setState(() {
_invalidateAuthAttempt();
widget.curOP.value = '';
});
}
Future<void> _updateState(int authAttempt) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op) {
_updateTimer?.cancel();
return Future<void>.value();
}
return bind.mainAccountAuthResult().then<void>((result) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op ||
result.isEmpty) {
return; return;
} }
final resultMap = jsonDecode(result); final resultMap = jsonDecode(result);
if (resultMap == null) { if (resultMap == null) {
return; return;
} }
final String stateMsg = resultMap['state_msg']; final String backendStateMsg = resultMap['state_msg'];
String failedMsg = resultMap['failed_msg']; String failedMsg = resultMap['failed_msg'];
final String? url = resultMap['url']; final String? url = resultMap['url'];
final stateMsg = backendStateMsg == _requestingAccountAuth &&
(url == null || url.isEmpty)
? _waitingAccountAuth
: backendStateMsg;
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false; final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
final authBody = resultMap['auth_body']; final authBody = resultMap['auth_body'];
if (_stateMsg != stateMsg || _failedMsg != failedMsg) { if (authBody != null) {
if (_url.isEmpty && url != null && url.isNotEmpty) { _updateTimer?.cancel();
if (!urlLaunched) { _invalidateAuthAttempt();
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); widget.curOP.value = '';
} widget.cbLogin(authBody as Map<String, dynamic>);
_url = url; return;
}
if (authBody != null) {
_updateTimer?.cancel();
widget.curOP.value = '';
widget.cbLogin(authBody as Map<String, dynamic>);
}
setState(() {
_stateMsg = stateMsg;
_failedMsg = failedMsg;
if (failedMsg.isNotEmpty) {
widget.curOP.value = '';
_updateTimer?.cancel();
}
});
} }
}); final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
if (!stateChanged && newUrl == null) {
return;
}
setState(() {
_stateMsg = stateMsg;
_failedMsg = failedMsg;
if (newUrl != null) {
_url = newUrl;
}
if (failedMsg.isNotEmpty) {
_invalidateAuthAttempt();
widget.curOP.value = '';
_updateTimer?.cancel();
}
});
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
unawaited(_launchAuthUrl(newUrl));
}
}).catchError(
(e) => _handleAuthFailure(
authAttempt,
e,
'query account authentication',
),
);
} }
_resetState() { int _resetState() {
_stateMsg = ''; _updateTimer?.cancel();
_failedMsg = ''; setState(() {
_url = ''; _invalidateAuthAttempt();
_stateMsg = _waitingAccountAuth;
_failedMsg = '';
});
return _authAttempt;
} }
@override @override
@@ -235,11 +472,31 @@ class _WidgetOPState extends State<WidgetOP> {
icon: widget.config.icon, icon: widget.config.icon,
primaryColor: str2color(widget.config.op, 0x7f), primaryColor: str2color(widget.config.op, 0x7f),
height: 36, height: 36,
canStartAuth: widget.canStartAuth,
onTap: () async { onTap: () async {
_resetState(); if (!widget.canStartAuth()) {
widget.curOP.value = widget.config.op; return;
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true); }
_beginQueryState(); final authAttempt = _resetState();
try {
final started = await widget.startAuth(widget.config.op);
if (!started) {
return;
}
} catch (e) {
await _handleAuthFailure(
authAttempt,
e,
'start account authentication',
);
return;
}
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op) {
return;
}
_beginQueryState(authAttempt);
}, },
), ),
Obx(() { Obx(() {
@@ -247,6 +504,8 @@ class _WidgetOPState extends State<WidgetOP> {
widget.curOP.value != widget.config.op) { widget.curOP.value != widget.config.op) {
_failedMsg = ''; _failedMsg = '';
} }
final authAttempt = _authAttempt;
final authUrl = _url;
return Offstage( return Offstage(
offstage: offstage:
_failedMsg.isEmpty && widget.curOP.value != widget.config.op, _failedMsg.isEmpty && widget.curOP.value != widget.config.op,
@@ -256,19 +515,27 @@ class _WidgetOPState extends State<WidgetOP> {
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty) if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: SelectableText( child: OidcAuthStatus(
translate(_stateMsg), message: translate(_stateMsg),
style: DefaultTextStyle.of(context) browserFallbackPrompt: translate(
.style "Browser didn't open? Use the url below to sign in.",
.copyWith(fontSize: 12), ),
authUrl: authUrl,
copyLabel: translate('Copy to clipboard'),
onCopy: authUrl.isEmpty
? null
: () => _runCurrentAuthUrlAction(
authAttempt,
authUrl,
_copyAuthUrl,
),
), ),
), ),
if (_failedMsg.isNotEmpty) if (_failedMsg.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Builder(builder: (context) { child: Builder(builder: (context) {
final errorColor = final errorColor = Theme.of(context).colorScheme.error;
Theme.of(context).colorScheme.error;
final bgColor = Theme.of(context) final bgColor = Theme.of(context)
.colorScheme .colorScheme
.errorContainer .errorContainer
@@ -289,12 +556,11 @@ class _WidgetOPState extends State<WidgetOP> {
Flexible( Flexible(
child: SelectableText( child: SelectableText(
translate(_failedMsg), translate(_failedMsg),
style: DefaultTextStyle.of(context) style:
.style DefaultTextStyle.of(context).style.copyWith(
.copyWith( fontSize: 13,
fontSize: 13, color: errorColor,
color: errorColor, ),
),
), ),
), ),
], ],
@@ -306,34 +572,6 @@ class _WidgetOPState extends State<WidgetOP> {
), ),
); );
}), }),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: const SizedBox(
height: 5.0,
),
),
),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 20),
child: ElevatedButton(
onPressed: () {
widget.curOP.value = '';
_updateTimer?.cancel();
_resetState();
bind.mainAccountAuthCancel();
},
child: Text(
translate('Cancel'),
style: TextStyle(fontSize: 15),
),
),
),
),
),
], ],
); );
} }
@@ -343,12 +581,18 @@ class LoginWidgetOP extends StatelessWidget {
final List<ConfigOP> ops; final List<ConfigOP> ops;
final RxString curOP; final RxString curOP;
final Function(Map<String, dynamic>) cbLogin; final Function(Map<String, dynamic>) cbLogin;
final Future<bool> Function(String) startAuth;
final Future<bool> Function(String) cancelAuth;
final bool Function() canStartAuth;
LoginWidgetOP({ LoginWidgetOP({
Key? key, Key? key,
required this.ops, required this.ops,
required this.curOP, required this.curOP,
required this.cbLogin, required this.cbLogin,
required this.startAuth,
required this.cancelAuth,
required this.canStartAuth,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -359,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget {
config: op, config: op,
curOP: curOP, curOP: curOP,
cbLogin: cbLogin, cbLogin: cbLogin,
startAuth: startAuth,
cancelAuth: cancelAuth,
canStartAuth: canStartAuth,
), ),
const Divider( const Divider(
indent: 5, indent: 5,
@@ -436,12 +683,11 @@ class LoginWidgetUserPass extends StatelessWidget {
translate('Login'), translate('Login'),
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
), ),
onPressed: onPressed: curOP.value.isEmpty && !isInProgress
curOP.value.isEmpty || curOP.value == 'rustdesk' ? () {
? () { onLogin();
onLogin(); }
} : null,
: null,
)), )),
), ),
])), ])),
@@ -452,8 +698,28 @@ class LoginWidgetUserPass extends StatelessWidget {
const kAuthReqTypeOidc = 'oidc/'; const kAuthReqTypeOidc = 'oidc/';
Future<bool?>? _activeLoginDialog;
// call this directly // call this directly
Future<bool?> loginDialog() async { Future<bool?> loginDialog() {
final activeDialog = _activeLoginDialog;
if (activeDialog != null) {
return activeDialog;
}
final dialog = _openLoginDialogOnce();
_activeLoginDialog = dialog;
return dialog;
}
Future<bool?> _openLoginDialogOnce() async {
try {
return await _openLoginDialog();
} finally {
_activeLoginDialog = null;
}
}
Future<bool?> _openLoginDialog() async {
var username = var username =
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? ''); TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
var password = TextEditingController(); var password = TextEditingController();
@@ -463,14 +729,28 @@ Future<bool?> loginDialog() async {
String? usernameMsg; String? usernameMsg;
String? passwordMsg; String? passwordMsg;
var isInProgress = false; var isInProgress = false;
final RxString curOP = ''.obs; final oidcAuth = _OidcAuthController();
final curOP = oidcAuth.curOP;
// Track hover state for the close icon // Track hover state for the close icon
bool isCloseHovered = false; bool isCloseHovered = false;
final loginOptions = [].obs; final loginOptions = [].obs;
Future.delayed(Duration.zero, () async { final loginOptionsError = Rxn<Object>();
loginOptions.value = await UserModel.queryOidcLoginOptions(); final loginOptionsInProgress = false.obs;
}); fetchLoginOptions() async {
loginOptionsInProgress.value = true;
try {
loginOptions.value = await UserModel.queryOidcLoginOptions();
loginOptionsError.value = null;
} catch (e) {
debugPrint("queryOidcLoginOptions failed: $e");
loginOptionsError.value = e;
} finally {
loginOptionsInProgress.value = false;
}
}
Future.delayed(Duration.zero, fetchLoginOptions);
final res = await gFFI.dialogManager.show<bool>((setState, close, context) { final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
username.addListener(() { username.addListener(() {
@@ -544,6 +824,9 @@ Future<bool?> loginDialog() async {
} }
onLogin() async { onLogin() async {
if (curOP.value.isNotEmpty || isInProgress) {
return;
}
// validate // validate
if (username.text.isEmpty) { if (username.text.isEmpty) {
setState(() => usernameMsg = translate('Username missed')); setState(() => usernameMsg = translate('Username missed'));
@@ -574,6 +857,36 @@ Future<bool?> loginDialog() async {
} }
thirdAuthWidget() => Obx(() { thirdAuthWidget() => Obx(() {
final error = loginOptionsError.value;
final inProgress = loginOptionsInProgress.value;
if (error != null) {
return Column(
children: [
const SizedBox(height: 8.0),
// NOT use Offstage to wrap LinearProgressIndicator
if (inProgress) const LinearProgressIndicator(),
if (!inProgress && error is! RequestException)
Text(
translate('network_error_tip'),
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
onPressed: inProgress ? null : fetchLoginOptions,
child: Text(translate('Retry')),
),
if (!inProgress)
SelectableText(
error.toString(),
style: const TextStyle(fontSize: 11, color: Colors.red),
textAlign: TextAlign.center,
),
],
);
}
return Offstage( return Offstage(
offstage: loginOptions.isEmpty, offstage: loginOptions.isEmpty,
child: Column( child: Column(
@@ -594,6 +907,9 @@ Future<bool?> loginDialog() async {
.map((e) => ConfigOP(op: e['name'], icon: e['icon'])) .map((e) => ConfigOP(op: e['name'], icon: e['icon']))
.toList(), .toList(),
curOP: curOP, curOP: curOP,
startAuth: oidcAuth.start,
cancelAuth: oidcAuth.cancelCurrent,
canStartAuth: oidcAuth.canStart,
cbLogin: (Map<String, dynamic> authBody) async { cbLogin: (Map<String, dynamic> authBody) async {
LoginResponse? resp; LoginResponse? resp;
try { try {
@@ -675,7 +991,7 @@ Future<bool?> loginDialog() async {
onCancel: onDialogCancel, onCancel: onDialogCancel,
onSubmit: onLogin, onSubmit: onLogin,
); );
}); }).whenComplete(oidcAuth.close);
if (res != null) { if (res != null) {
await UserModel.updateOtherModels(); await UserModel.updateOtherModels();

View File

@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
const _statusFontSize = 12.0;
const _statusSpacing = 4.0;
const _messageActionSpacing = 8.0;
const _desktopActionSize = 28.0;
const _touchPlatforms = <TargetPlatform>{
TargetPlatform.android,
TargetPlatform.iOS,
TargetPlatform.fuchsia,
};
class OidcAuthStatus extends StatelessWidget {
final String message;
final String browserFallbackPrompt;
final String authUrl;
final String copyLabel;
final VoidCallback? onCopy;
const OidcAuthStatus({
super.key,
required this.message,
required this.browserFallbackPrompt,
required this.authUrl,
required this.copyLabel,
this.onCopy,
});
@override
Widget build(BuildContext context) {
final messageStyle =
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SelectableText(message, style: messageStyle),
if (authUrl.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _messageActionSpacing),
child: _OidcAuthFallback(
browserFallbackPrompt: browserFallbackPrompt,
authUrl: authUrl,
copyLabel: copyLabel,
onCopy: onCopy,
),
),
],
);
}
}
class _OidcAuthFallback extends StatefulWidget {
final String browserFallbackPrompt;
final String authUrl;
final String copyLabel;
final VoidCallback? onCopy;
const _OidcAuthFallback({
required this.browserFallbackPrompt,
required this.authUrl,
required this.copyLabel,
required this.onCopy,
});
@override
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
}
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
bool _expanded = false;
@override
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.authUrl != widget.authUrl) {
_expanded = false;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
fontSize: _statusFontSize,
color: theme.colorScheme.onSurfaceVariant,
);
final linkColor = theme.brightness == Brightness.dark
? Colors.blue.shade300
: Colors.blue.shade800;
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
final actionSize =
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
final urlStyle =
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.browserFallbackPrompt,
style: helperStyle,
textAlign: TextAlign.center,
),
Padding(
padding: const EdgeInsets.only(top: _statusSpacing),
child: _buildUrl(urlStyle, linkColor, actionSize),
),
],
);
}
void _copyAndExpand() {
setState(() => _expanded = true);
widget.onCopy?.call();
}
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
final collapsedUrl = SizedBox(
width: double.infinity,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: linkColor,
minimumSize: Size(0, actionSize),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
),
onPressed: _copyAndExpand,
child: Text(
widget.authUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: urlStyle.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
),
),
),
);
final collapsedChild = widget.onCopy == null
? collapsedUrl
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
return Container(
width: double.infinity,
constraints: BoxConstraints(minHeight: actionSize),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(_statusSpacing),
),
child: _expanded
? SelectableText(widget.authUrl, style: urlStyle)
: collapsedChild,
);
}
}

View File

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

View File

@@ -115,6 +115,7 @@ class _RawTouchGestureDetectorRegionState
InputModel get inputModel => widget.inputModel; InputModel get inputModel => widget.inputModel;
bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode; bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode;
SessionID get sessionId => ffi.sessionId; SessionID get sessionId => ffi.sessionId;
bool get canvasLocked => isMobile && ffi.canvasModel.locked;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -471,6 +472,8 @@ class _RawTouchGestureDetectorRegionState
return; return;
} }
if (canvasLocked) return;
if ((isDesktop || isWebDesktop)) { if ((isDesktop || isWebDesktop)) {
final scale = ((d.scale - _scale) * 1000).toInt(); final scale = ((d.scale - _scale) * 1000).toInt();
_scale = d.scale; _scale = d.scale;

View File

@@ -244,17 +244,54 @@ List<(String, String)> otherDefaultSettings() {
kKeyUseAllMyDisplaysForTheRemoteSession kKeyUseAllMyDisplaysForTheRemoteSession
), ),
('Keep terminal sessions on disconnect', kOptionTerminalPersistent), ('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
(
'Allow terminal apps to copy to clipboard',
kOptionAllowTerminalClipboardWrite
),
]; ];
return v; return v;
} }
String getOtherDefaultSettingOption(String key) {
if (key == kOptionAllowTerminalClipboardWrite) {
return bind.mainGetLocalOption(key: key);
}
return bind.mainGetUserDefaultOption(key: key);
}
Future<void> setOtherDefaultSettingOption(String key, String value) {
if (key == kOptionAllowTerminalClipboardWrite) {
return bind.mainSetLocalOption(
key: key,
value: value == kTerminalClipboardWriteAllowed
? kTerminalClipboardWriteAllowed
: kTerminalClipboardWriteDenied,
);
}
return bind.mainSetUserDefaultOption(key: key, value: value);
}
bool isOtherDefaultSettingReadOnly(String key) =>
isOptionFixed(key) ||
(key == kOptionAllowTerminalClipboardWrite && bind.isDisableSettings());
class TrackpadSpeedWidget extends StatefulWidget { class TrackpadSpeedWidget extends StatefulWidget {
final SimpleWrapper<int> value; final SimpleWrapper<int> value;
// If null, no debouncer will be applied. // If null, no debouncer will be applied.
final Function(int)? onDebouncer; final Function(int)? onDebouncer;
final ValueChanged<String>? onTextChanged;
// IME actions call TextField.onSubmitted without reaching the dialog's
// raw Enter handler, so the dialog needs a separate submission callback.
final ValueChanged<String>? onTextSubmitted;
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer}); TrackpadSpeedWidget({
Key? key,
required this.value,
this.onDebouncer,
this.onTextChanged,
this.onTextSubmitted,
});
@override @override
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState(); TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
@@ -276,6 +313,34 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
debouncerSpeed.setValue(value); debouncerSpeed.setValue(value);
} }
}); });
widget.onTextChanged?.call(_controller.text);
}
void updateTextValue(String text) {
widget.onTextChanged?.call(text);
final newValue = int.tryParse(text);
if (newValue == null ||
newValue < kMinTrackpadSpeed ||
newValue > kMaxTrackpadSpeed) {
return;
}
setState(() => value = newValue);
}
void submitTextValue(String text) {
final onTextSubmitted = widget.onTextSubmitted;
if (onTextSubmitted != null) {
onTextSubmitted(text);
return;
}
if (widget.onTextChanged != null) {
return;
}
final newValue = int.tryParse(text);
if (newValue == null) {
return;
}
updateValue(newValue);
} }
@override @override
@@ -315,12 +380,8 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
controller: _controller, controller: _controller,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textAlign: TextAlign.center, textAlign: TextAlign.center,
onSubmitted: (text) { onChanged: updateTextValue,
int? v = int.tryParse(text); onSubmitted: submitTextValue,
if (v != null) {
updateValue(v);
}
},
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: contentPadding:

View File

@@ -349,12 +349,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
showRequestElevationDialog(sessionId, ffi.dialogManager)), showRequestElevationDialog(sessionId, ffi.dialogManager)),
); );
} }
// osAccount / osPassword // osPassword
if (isDefaultConn && perms['keyboard'] != false) { if (isDefaultConn && perms['keyboard'] != false) {
v.add( v.add(
TTextMenu( TTextMenu(
child: Row(children: [ child: Row(children: [
Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')), Text(translate('OS Password')),
]), ]),
trailingIcon: Transform.scale( trailingIcon: Transform.scale(
scale: (isDesktop || isWebDesktop) ? 0.8 : 1, scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
@@ -363,18 +363,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
if (isMobile && Navigator.canPop(context)) { if (isMobile && Navigator.canPop(context)) {
Navigator.pop(context); Navigator.pop(context);
} }
if (pi.isHeadless) { handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
showSetOSAccount(sessionId, ffi.dialogManager);
} else {
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
}
}, },
icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null), icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null),
), ),
), ),
onPressed: () => pi.isHeadless onPressed: () => handleOsPasswordAction(sessionId, ffi.dialogManager),
? showSetOSAccount(sessionId, ffi.dialogManager)
: handleOsPasswordAction(sessionId, ffi.dialogManager),
), ),
); );
} }
@@ -583,6 +577,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
} }
// record // record
if (!(isDesktop || isWeb) && if (!(isDesktop || isWeb) &&
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
(ffi.recordingModel.start || (perms["recording"] != false))) { (ffi.recordingModel.start || (perms["recording"] != false))) {
v.add(TTextMenu( v.add(TTextMenu(
child: Row( child: Row(

View File

@@ -18,7 +18,6 @@ const kKeyMapMode = 'map';
const kKeyTranslateMode = 'translate'; const kKeyTranslateMode = 'translate';
const String kPlatformAdditionsIsWayland = "is_wayland"; const String kPlatformAdditionsIsWayland = "is_wayland";
const String kPlatformAdditionsHeadless = "headless";
const String kPlatformAdditionsIsInstalled = "is_installed"; const String kPlatformAdditionsIsInstalled = "is_installed";
const String kPlatformAdditionsIddImpl = "idd_impl"; const String kPlatformAdditionsIddImpl = "idd_impl";
const String kPlatformAdditionsRustDeskVirtualDisplays = const String kPlatformAdditionsRustDeskVirtualDisplays =
@@ -55,7 +54,6 @@ const String kAppTypeDesktopTerminal = "terminal";
const String kWindowMainWindowOnTop = "main_window_on_top"; const String kWindowMainWindowOnTop = "main_window_on_top";
const String kWindowRefreshCurrentUser = "refresh_current_user"; const String kWindowRefreshCurrentUser = "refresh_current_user";
const String kWindowGetWindowInfo = "get_window_info";
const String kWindowGetScreenList = "get_screen_list"; const String kWindowGetScreenList = "get_screen_list";
// This method is not used, maybe it can be removed. // This method is not used, maybe it can be removed.
const String kWindowDisableGrabKeyboard = "disable_grab_keyboard"; const String kWindowDisableGrabKeyboard = "disable_grab_keyboard";
@@ -95,6 +93,7 @@ const String kOptionForceAlwaysRelay = "force-always-relay";
const String kOptionViewOnly = "view_only"; const String kOptionViewOnly = "view_only";
const String kOptionEnableLanDiscovery = "enable-lan-discovery"; const String kOptionEnableLanDiscovery = "enable-lan-discovery";
const String kOptionWhitelist = "whitelist"; const String kOptionWhitelist = "whitelist";
const String kOptionIdWhitelist = "id-whitelist";
const String kOptionEnableAbr = "enable-abr"; const String kOptionEnableAbr = "enable-abr";
const String kOptionEnableRecordSession = "enable-record-session"; const String kOptionEnableRecordSession = "enable-record-session";
const String kOptionDirectServer = "direct-server"; const String kOptionDirectServer = "direct-server";
@@ -104,6 +103,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
const String kOptionEnableHwcodec = "enable-hwcodec"; const String kOptionEnableHwcodec = "enable-hwcodec";
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming"; const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing"; const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
const String kOptionHideRecordingButton = "hide-recording-button";
const String kOptionVideoSaveDirectory = "video-save-directory"; const String kOptionVideoSaveDirectory = "video-save-directory";
const String kOptionAccessMode = "access-mode"; const String kOptionAccessMode = "access-mode";
const String kOptionEnableKeyboard = "enable-keyboard"; const String kOptionEnableKeyboard = "enable-keyboard";
@@ -115,6 +115,11 @@ const String kOptionEnableAudio = "enable-audio";
const String kOptionEnableCamera = "enable-camera"; const String kOptionEnableCamera = "enable-camera";
const String kOptionEnableTerminal = "enable-terminal"; const String kOptionEnableTerminal = "enable-terminal";
const String kOptionTerminalPersistent = "terminal-persistent"; const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionAllowTerminalClipboardWrite =
"allow-terminal-clipboard-write";
const String kTerminalClipboardWriteUnconfigured = "";
const String kTerminalClipboardWriteAllowed = "Y";
const String kTerminalClipboardWriteDenied = "N";
const String kOptionEnableTunnel = "enable-tunnel"; const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart"; const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input"; const String kOptionEnableBlockInput = "enable-block-input";
@@ -159,16 +164,20 @@ const String kOptionPeerTabVisible = "peer-tab-visible";
const String kOptionPeerCardUiType = "peer-card-ui-type"; const String kOptionPeerCardUiType = "peer-card-ui-type";
const String kOptionCurrentAbName = "current-ab-name"; const String kOptionCurrentAbName = "current-ab-name";
const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs"; const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
const String kOptionEnablePortForwardMux = "enable-port-forward-mux";
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render"; const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
const String kOptionEnableCheckUpdate = "enable-check-update"; const String kOptionEnableCheckUpdate = "enable-check-update";
const String kOptionAllowAutoUpdate = "allow-auto-update"; const String kOptionAllowAutoUpdate = "allow-auto-update";
const String kOptionAllowLinuxHeadless = "allow-linux-headless";
const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper"; const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
const String kOptionStopService = "stop-service"; const String kOptionStopService = "stop-service";
const String kOptionDirectxCapture = "enable-directx-capture"; const String kOptionDirectxCapture = "enable-directx-capture";
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification"; const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
const String kOptionEnableTcpPunch = "enable-tcp-punch";
const String kOptionEnableUdpPunch = "enable-udp-punch"; const String kOptionEnableUdpPunch = "enable-udp-punch";
const String kOptionEnableIpv6Punch = "enable-ipv6-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 kOptionEnableTrustedDevices = "enable-trusted-devices";
const String kOptionShowVirtualMouse = "show-virtual-mouse"; const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale"; const String kOptionVirtualMouseScale = "virtual-mouse-scale";
@@ -177,6 +186,7 @@ const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar"; const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar"; const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
// network options // network options
const String kOptionAllowWebSocket = "allow-websocket"; const String kOptionAllowWebSocket = "allow-websocket";
@@ -190,6 +200,7 @@ const String kOptionHideProxySetting = "hide-proxy-settings";
const String kOptionHideWebSocketSetting = "hide-websocket-settings"; const String kOptionHideWebSocketSetting = "hide-websocket-settings";
const String kOptionHideStopService = "hide-stop-service"; const String kOptionHideStopService = "hide-stop-service";
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings"; const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
const String kOptionHideGeneralSetting = "hide-general-settings";
const String kOptionHideSecuritySetting = "hide-security-settings"; const String kOptionHideSecuritySetting = "hide-security-settings";
const String kOptionHideNetworkSetting = "hide-network-settings"; const String kOptionHideNetworkSetting = "hide-network-settings";
const String kOptionRemovePresetPasswordWarning = const String kOptionRemovePresetPasswordWarning =
@@ -322,10 +333,11 @@ double kNewWindowOffset = isWindows
? 30.0 ? 30.0
: 50.0; : 50.0;
const kDragToResizeAreaPaddingSize = 5.0;
EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux
? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value ? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value
? EdgeInsets.zero ? EdgeInsets.zero
: EdgeInsets.all(5.0) : EdgeInsets.all(kDragToResizeAreaPaddingSize)
: EdgeInsets.zero; : EdgeInsets.zero;
// https://en.wikipedia.org/wiki/Non-breaking_space // https://en.wikipedia.org/wiki/Non-breaking_space
const int $nbsp = 0x00A0; const int $nbsp = 0x00A0;
@@ -437,7 +449,6 @@ const kActionApplicationDetailsSettings =
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS"; const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
const kRecordAudio = "android.permission.RECORD_AUDIO"; const kRecordAudio = "android.permission.RECORD_AUDIO";
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
const kRequestIgnoreBatteryOptimizations = const kRequestIgnoreBatteryOptimizations =
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"; "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW"; const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
@@ -449,6 +460,12 @@ class AndroidChannel {
static final kGetStartOnBootOpt = "get_start_on_boot_opt"; static final kGetStartOnBootOpt = "get_start_on_boot_opt";
static final kSetStartOnBootOpt = "set_start_on_boot_opt"; static final kSetStartOnBootOpt = "set_start_on_boot_opt";
static final kSyncAppDirConfigPath = "sync_app_dir"; static final kSyncAppDirConfigPath = "sync_app_dir";
static final kPickImportFiles = "pick_import_files";
static final kImportFile = "import_file";
static final kExportFile = "export_file";
static final kPickImportDirectory = "pick_import_directory";
static final kImportDirectory = "import_directory";
static final kExportFiles = "export_files";
} }
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels /// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels

View File

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

View File

@@ -16,7 +16,6 @@ import 'package:flutter_hbb/desktop/widgets/update_progress.dart';
import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/plugin/ui_manager.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/utils/platform_channel.dart'; import 'package:flutter_hbb/utils/platform_channel.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -111,7 +110,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
} }
}, },
), ),
buildPluginEntry(),
]; ];
if (isIncomingOnly) { if (isIncomingOnly) {
children.addAll([ children.addAll([
@@ -782,13 +780,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
windowOnTop(null); windowOnTop(null);
} else if (call.method == kWindowRefreshCurrentUser) { } else if (call.method == kWindowRefreshCurrentUser) {
gFFI.userModel.refreshCurrentUser(); gFFI.userModel.refreshCurrentUser();
} else if (call.method == kWindowGetWindowInfo) {
final screen = (await window_size.getWindowInfo()).screen;
if (screen == null) {
return '';
} else {
return jsonEncode(screenToMap(screen));
}
} else if (call.method == kWindowGetScreenList) { } else if (call.method == kWindowGetScreenList) {
return jsonEncode( return jsonEncode(
(await window_size.getScreenList()).map(screenToMap).toList()); (await window_size.getScreenList()).map(screenToMap).toList());
@@ -890,21 +881,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
shouldBeBlocked(_block, canBeBlocked); shouldBeBlocked(_block, canBeBlocked);
} }
} }
Widget buildPluginEntry() {
final entries = PluginUiManager.instance.entries.entries;
return Offstage(
offstage: entries.isEmpty,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...entries.map((entry) {
return entry.value;
})
],
),
);
}
} }
void setPasswordDialog({VoidCallback? notEmptyCallback}) async { void setPasswordDialog({VoidCallback? notEmptyCallback}) async {

View File

@@ -17,8 +17,6 @@ import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/printer_model.dart';
import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/plugin/manager.dart';
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -55,7 +53,6 @@ enum SettingsTabKey {
safety, safety,
network, network,
display, display,
plugin,
account, account,
printer, printer,
about, about,
@@ -64,7 +61,8 @@ enum SettingsTabKey {
class DesktopSettingPage extends StatefulWidget { class DesktopSettingPage extends StatefulWidget {
final SettingsTabKey initialTabkey; final SettingsTabKey initialTabkey;
static final List<SettingsTabKey> tabKeys = [ static final List<SettingsTabKey> tabKeys = [
SettingsTabKey.general, if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y')
SettingsTabKey.general,
if (!isWeb && if (!isWeb &&
!bind.isOutgoingOnly() && !bind.isOutgoingOnly() &&
!bind.isDisableSettings() && !bind.isDisableSettings() &&
@@ -74,10 +72,9 @@ class DesktopSettingPage extends StatefulWidget {
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y') bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y')
SettingsTabKey.network, SettingsTabKey.network,
if (!bind.isIncomingOnly()) SettingsTabKey.display, if (!bind.isIncomingOnly()) SettingsTabKey.display,
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
SettingsTabKey.plugin,
if (!bind.isDisableAccount()) SettingsTabKey.account, if (!bind.isDisableAccount()) SettingsTabKey.account,
if (isWindows && if (isWindows &&
!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y') bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y')
SettingsTabKey.printer, SettingsTabKey.printer,
SettingsTabKey.about, SettingsTabKey.about,
@@ -95,7 +92,8 @@ class DesktopSettingPage extends StatefulWidget {
if (index == -1) { if (index == -1) {
return; return;
} }
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) { if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag) &&
Get.isRegistered<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag)) {
DesktopTabPage.onAddSetting(initialPage: page); DesktopTabPage.onAddSetting(initialPage: page);
PageController controller = PageController controller =
Get.find<PageController>(tag: _kSettingPageControllerTag); Get.find<PageController>(tag: _kSettingPageControllerTag);
@@ -163,17 +161,23 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
if (!mounted) { if (!mounted) {
return; return;
} }
_canBeBlocked.value = await canBeBlocked(); final blocked = await canBeBlocked();
if (!mounted) {
return;
}
_canBeBlocked.value = blocked;
}); });
} }
@override @override
void dispose() { void dispose() {
super.dispose();
Get.delete<PageController>(tag: _kSettingPageControllerTag);
Get.delete<RxInt>(tag: _kSettingPageTabKeyTag);
WidgetsBinding.instance.removeObserver(this);
_videoConnTimer?.cancel(); _videoConnTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
Get.delete<PageController>(tag: _kSettingPageControllerTag);
Get.delete<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
// Get.delete does not dispose a plain ChangeNotifier.
controller.dispose();
super.dispose();
} }
List<_TabInfo> _settingTabs() { List<_TabInfo> _settingTabs() {
@@ -196,10 +200,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
settingTabs.add(_TabInfo(tab, 'Display', settingTabs.add(_TabInfo(tab, 'Display',
Icons.desktop_windows_outlined, Icons.desktop_windows)); Icons.desktop_windows_outlined, Icons.desktop_windows));
break; break;
case SettingsTabKey.plugin:
settingTabs.add(_TabInfo(
tab, 'Plugin', Icons.extension_outlined, Icons.extension));
break;
case SettingsTabKey.account: case SettingsTabKey.account:
settingTabs.add( settingTabs.add(
_TabInfo(tab, 'Account', Icons.person_outline, Icons.person)); _TabInfo(tab, 'Account', Icons.person_outline, Icons.person));
@@ -233,9 +233,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
case SettingsTabKey.display: case SettingsTabKey.display:
children.add(const _Display()); children.add(const _Display());
break; break;
case SettingsTabKey.plugin:
children.add(const _Plugin());
break;
case SettingsTabKey.account: case SettingsTabKey.account:
children.add(const _Account()); children.add(const _Account());
break; break;
@@ -485,7 +482,8 @@ class _GeneralState extends State<_General> {
Widget other() { Widget other() {
final incomingOnly = bind.isIncomingOnly(); final incomingOnly = bind.isIncomingOnly();
final outgoingOnly = bind.isOutgoingOnly(); final outgoingOnly = bind.isOutgoingOnly();
final showAutoUpdate = isWindows && bind.mainIsInstalled(); final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
final children = <Widget>[ final children = <Widget>[
if (!isWeb && !incomingOnly) if (!isWeb && !incomingOnly)
_OptionCheckBox(context, 'Confirm before closing multiple tabs', _OptionCheckBox(context, 'Confirm before closing multiple tabs',
@@ -511,6 +509,15 @@ class _GeneralState extends State<_General> {
kOptionOpenNewConnInTabs, kOptionOpenNewConnInTabs,
isServer: false, isServer: false,
), ),
Tooltip(
message: translate('port-forward-mux-tip'),
child: _OptionCheckBox(
context,
'Reuse one connection for port forwarding',
kOptionEnablePortForwardMux,
isServer: false,
),
),
// though this is related to GUI, but opengl problem affects all users, so put in config rather than local // though this is related to GUI, but opengl problem affects all users, so put in config rather than local
if (isLinux) if (isLinux)
Tooltip( Tooltip(
@@ -565,6 +572,12 @@ class _GeneralState extends State<_General> {
kOptionDirectxCapture, kOptionDirectxCapture,
), ),
if (!isWeb && !incomingOnly) ...[ if (!isWeb && !incomingOnly) ...[
_OptionCheckBox(
context,
'Enable TCP hole punching',
kOptionEnableTcpPunch,
isServer: false,
),
_OptionCheckBox( _OptionCheckBox(
context, context,
'Enable UDP hole punching', 'Enable UDP hole punching',
@@ -578,6 +591,23 @@ class _GeneralState extends State<_General> {
isServer: false, 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(
context,
'Sync clipboard between sessions',
kOptionAllowSyncClipboardBetweenSessions,
isServer: false,
),
),
]; ];
// Add client-side wakelock option for desktop platforms // Add client-side wakelock option for desktop platforms
@@ -590,10 +620,6 @@ class _GeneralState extends State<_General> {
)); ));
} }
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
children.add(_OptionCheckBox(
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
}
if (!bind.isDisableAccount()) { if (!bind.isDisableAccount()) {
children.add(_OptionCheckBox( children.add(_OptionCheckBox(
context, context,
@@ -1297,6 +1323,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
reverse: true, enabled: enabled), reverse: true, enabled: enabled),
...directIp(context), ...directIp(context),
whitelist(), whitelist(),
idWhitelist(),
...autoDisconnect(context), ...autoDisconnect(context),
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label', _OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
kOptionKeepAwakeDuringIncomingSessions, kOptionKeepAwakeDuringIncomingSessions,
@@ -1454,6 +1481,52 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
return tmpWrapper(); return tmpWrapper();
} }
Widget idWhitelist() {
bool enabled = !locked;
RxBool hasIdWhitelist = idWhitelistNotEmpty().obs;
update() async {
hasIdWhitelist.value = idWhitelistNotEmpty();
}
onChanged(bool? checked) async {
changeIdWhiteList(callback: update);
}
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
return GestureDetector(
child: Tooltip(
message: translate('id_whitelist_tip'),
child: Obx(() => Row(
children: [
Checkbox(
value: hasIdWhitelist.value,
onChanged: enabled && !isOptFixed ? onChanged : null)
.marginOnly(right: 5),
Offstage(
offstage: !hasIdWhitelist.value,
child: MouseRegion(
child: const Icon(Icons.warning_amber_rounded,
color: Color.fromARGB(255, 255, 204, 0))
.marginOnly(right: 5),
cursor: SystemMouseCursors.click,
),
),
Expanded(
child: Text(
translate('Use ID whitelisting'),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
],
)),
),
onTap: enabled
? () {
onChanged(!hasIdWhitelist.value);
}
: null,
).marginOnly(left: _kCheckBoxLeftMargin);
}
Widget hide_cm(bool enabled) { Widget hide_cm(bool enabled) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: gFFI.serverModel, value: gFFI.serverModel,
@@ -2030,14 +2103,13 @@ class _DisplayState extends State<_Display> {
} }
Widget otherRow(String label, String key) { Widget otherRow(String label, String key) {
final value = bind.mainGetUserDefaultOption(key: key) == 'Y'; final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOptionFixed(key); final isOptFixed = isOtherDefaultSettingReadOnly(key);
onChanged(bool b) async { onChanged(bool b) async {
await bind.mainSetUserDefaultOption( await setOtherDefaultSettingOption(
key: key, key,
value: b b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
? 'Y' );
: (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo));
setState(() {}); setState(() {});
} }
@@ -2207,51 +2279,6 @@ class _CheckboxState extends State<_Checkbox> {
} }
} }
class _Plugin extends StatefulWidget {
const _Plugin({Key? key}) : super(key: key);
@override
State<_Plugin> createState() => _PluginState();
}
class _PluginState extends State<_Plugin> {
@override
Widget build(BuildContext context) {
bind.pluginListReload();
final scrollController = ScrollController();
return ChangeNotifierProvider.value(
value: pluginManager,
child: Consumer<PluginManager>(builder: (context, model, child) {
return ListView(
controller: scrollController,
children: model.plugins.map((entry) => pluginCard(entry)).toList(),
).marginOnly(bottom: _kListViewBottomMargin);
}),
);
}
Widget pluginCard(PluginInfo plugin) {
return ChangeNotifierProvider.value(
value: plugin,
child: Consumer<PluginInfo>(
builder: (context, model, child) => DesktopSettingsCard(plugin: model),
),
);
}
Widget accountAction() {
return Obx(() => _Button(
gFFI.userModel.userName.value.isEmpty
? 'Login'
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
: logOutConfirmDialog()
}));
}
}
class _Printer extends StatefulWidget { class _Printer extends StatefulWidget {
const _Printer({super.key}); const _Printer({super.key});
@@ -2414,17 +2441,20 @@ class _AboutState extends State<_About> {
final version = await bind.mainGetVersion(); final version = await bind.mainGetVersion();
final buildDate = await bind.mainGetBuildDate(); final buildDate = await bind.mainGetBuildDate();
final fingerprint = await bind.mainGetFingerprint(); final fingerprint = await bind.mainGetFingerprint();
final myId = await bind.mainGetMyId();
return { return {
'license': license, 'license': license,
'version': version, 'version': version,
'buildDate': buildDate, 'buildDate': buildDate,
'fingerprint': fingerprint 'fingerprint': fingerprint,
'myId': myId
}; };
}(), hasData: (data) { }(), hasData: (data) {
final license = data['license'].toString(); final license = data['license'].toString();
final version = data['version'].toString(); final version = data['version'].toString();
final buildDate = data['buildDate'].toString(); final buildDate = data['buildDate'].toString();
final fingerprint = data['fingerprint'].toString(); final fingerprint = data['fingerprint'].toString();
final myId = data['myId'].toString();
const linkStyle = TextStyle(decoration: TextDecoration.underline); const linkStyle = TextStyle(decoration: TextDecoration.underline);
final scrollController = ScrollController(); final scrollController = ScrollController();
return SingleChildScrollView( return SingleChildScrollView(
@@ -2446,6 +2476,9 @@ class _AboutState extends State<_About> {
SelectionArea( SelectionArea(
child: Text('${translate('Fingerprint')}: $fingerprint') child: Text('${translate('Fingerprint')}: $fingerprint')
.marginSymmetric(vertical: 4.0)), .marginSymmetric(vertical: 4.0)),
SelectionArea(
child: Text('${translate('ID')}: $myId')
.marginSymmetric(vertical: 4.0)),
InkWell( InkWell(
onTap: () { onTap: () {
launchUrlString('https://rustdesk.com/privacy.html'); launchUrlString('https://rustdesk.com/privacy.html');

View File

@@ -278,7 +278,39 @@ class _FileManagerPageState extends State<FileManagerPage>
item.state != JobState.inProgress, item.state != JobState.inProgress,
child: LinearPercentIndicator( child: LinearPercentIndicator(
animateFromLastPercent: true, animateFromLastPercent: true,
center: Text(item.percentText), center: SizedBox.expand(
child: ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) =>
LinearGradient(
colors: [
Colors.white,
Colors.transparent,
],
stops: [item.percent, item.percent],
).createShader(bounds),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text.rich(
TextSpan(
text: item.percentText,
children: [
if (item.recvJobRes)
TextSpan(
text:
' ${readableFileSize(item.speed)}/s',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w300,
color: MyTheme.darkGray,
),
),
],
),
),
),
),
),
barRadius: Radius.circular(15), barRadius: Radius.circular(15),
percent: item.percent, percent: item.percent,
progressColor: MyTheme.accent, progressColor: MyTheme.accent,
@@ -1094,6 +1126,7 @@ class _FileManagerViewState extends State<FileManagerView> {
return element.name.contains(_searchText.value); return element.name.contains(_searchText.value);
}).toList(growable: false) }).toList(growable: false)
: entries; : entries;
// Keep rows lazy so large directories only build visible list items.
final rows = filteredEntries.map((entry) { final rows = filteredEntries.map((entry) {
final sizeStr = final sizeStr =
entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; entry.isFile ? readableFileSize(entry.size.toDouble()) : "";
@@ -1276,7 +1309,7 @@ class _FileManagerViewState extends State<FileManagerView> {
], ],
))), ))),
); );
}).toList(growable: false); });
return Column( return Column(
children: [ children: [
@@ -1292,7 +1325,7 @@ class _FileManagerViewState extends State<FileManagerView> {
controller: scrollController, controller: scrollController,
itemExtent: kDesktopFileTransferRowHeight, itemExtent: kDesktopFileTransferRowHeight,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return rows[index]; return rows.elementAt(index);
}, },
itemCount: rows.length, itemCount: rows.length,
), ),

View File

@@ -0,0 +1,24 @@
class MacOSFullScreenFocusRecovery {
int _generation = 0;
int? _pendingGeneration;
int? get pendingGeneration => _pendingGeneration;
int queue() {
_generation += 1;
_pendingGeneration = _generation;
return _generation;
}
void cancel() {
_pendingGeneration = null;
}
bool isCurrent(int generation) => _pendingGeneration == generation;
bool consume(int generation) {
if (!isCurrent(generation)) return false;
_pendingGeneration = null;
return true;
}
}

View File

@@ -22,6 +22,7 @@ import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart'; import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart'; import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/tabbar_widget.dart'; import '../widgets/tabbar_widget.dart';
import 'macos_full_screen_focus_recovery.dart';
import 'package:flutter_hbb/native/custom_cursor.dart' import 'package:flutter_hbb/native/custom_cursor.dart'
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart'; if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
@@ -64,6 +65,13 @@ class RemotePage extends StatefulWidget {
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi; FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
void releaseMacOSInputForTabTransfer() {
if (!isMacOS) return;
// Release before removing the source tab. Its delayed disposal must not
// disable a native keyboard hook already acquired by the destination page.
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
}
@override @override
State<RemotePage> createState() { State<RemotePage> createState() {
final state = _RemotePageState(id); final state = _RemotePageState(id);
@@ -76,10 +84,28 @@ class _RemotePageState extends State<RemotePage>
with with
AutomaticKeepAliveClientMixin, AutomaticKeepAliveClientMixin,
MultiWindowListener, MultiWindowListener,
WidgetsBindingObserver,
TickerProviderStateMixin { TickerProviderStateMixin {
Timer? _timer; Timer? _timer;
String keyboardMode = "legacy"; String keyboardMode = "legacy";
bool _isWindowBlur = false; bool _isWindowBlur = false;
// Known macOS remote-input trade-offs (kept simple intentionally):
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
// state. Reproduce: activate remote input, open a dialog, then type.
// 2. Delayed fullscreen recovery can race a local-control focus change; no
// owner state is added. Reproduce: focus the toolbar during a Space switch.
// 3. Input-source switching releases native input without updating this
// page's cache. Reproduce: switch sources, then type before and after
// clicking the remote image; the click reasserts input.
// These latches compensate for out-of-order macOS focus events. Treat them
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
AppLifecycleState? _macOSLifecycleState;
bool _macOSLocalFocusLost = false;
bool _macOSInputActive = false;
bool _macOSInputSuppressed = false;
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
bool _macOSExplicitFocusRequestPending = false;
StreamSubscription<DesktopTabState>? _tabStateSubscription;
final _cursorOverImage = false.obs; final _cursorOverImage = false.obs;
late RxBool _showRemoteCursor; late RxBool _showRemoteCursor;
late RxBool _zoomCursor; late RxBool _zoomCursor;
@@ -122,6 +148,13 @@ class _RemotePageState extends State<RemotePage>
void initState() { void initState() {
super.initState(); super.initState();
_ffi = FFI(widget.sessionId); _ffi = FFI(widget.sessionId);
if (isMacOS) {
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
WidgetsBinding.instance.addObserver(this);
_tabStateSubscription =
widget.tabController?.state.listen(_onMacOSTabStateChanged);
}
Get.put<FFI>(_ffi, tag: widget.id); Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) { _ffi.imageModel.addCallbackOnFirstImage((String peerId) {
_ffi.canvasModel.activateLocalCursor(); _ffi.canvasModel.activateLocalCursor();
@@ -149,7 +182,6 @@ class _RemotePageState extends State<RemotePage>
WakelockManager.enable(_uniqueKey); WakelockManager.enable(_uniqueKey);
_ffi.ffiModel.updateEventListener(sessionId, widget.id); _ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
_ffi.dialogManager.loadMobileActionsOverlayVisible(); _ffi.dialogManager.loadMobileActionsOverlayVisible();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -231,19 +263,229 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer = null; _pointerLockCenterDebounceTimer = null;
} }
bool get _isSelectedTab {
final controller = widget.tabController;
if (controller == null) return true;
final tabState = controller.state.value;
final selected = tabState.selected;
return selected >= 0 &&
selected < tabState.tabs.length &&
tabState.tabs[selected].key == widget.id;
}
// Every Windows requestFocus() must pass this, or a blocking dialog or an
// inactive tab could hand remote input to this page.
bool get _windowsCanFocusRemoteInput =>
_isSelectedTab && _blockableOverlayState.middleBlocked.isFalse;
bool get _isMacOSKeyboardContextActive {
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
}
void _onMacOSTabStateChanged(DesktopTabState _) {
if (!_isSelectedTab) {
_macOSFullScreenFocusRecovery.cancel();
_syncMacOSKeyboardGrab();
return;
}
// Tab listeners run synchronously. Defer the selected page so the previous
// page releases first; a late leave from it can disable the new session.
scheduleMicrotask(() {
if (mounted) {
_syncMacOSKeyboardGrab(reassert: true);
}
});
}
void _releaseMacOSRemoteInput() {
_macOSFullScreenFocusRecovery.cancel();
_macOSExplicitFocusRequestPending = false;
_macOSInputSuppressed = true;
_macOSLocalFocusLost = true;
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
_rawKeyFocusNode.unfocus();
}
void _onMacOSFocusChange() {
// requestFocus() notifies later; only a recorded explicit request may clear
// the local-focus-loss latch.
if (_rawKeyFocusNode.hasPrimaryFocus) {
final explicitRequest = _macOSExplicitFocusRequestPending;
_macOSExplicitFocusRequestPending = false;
if (explicitRequest && _isMacOSKeyboardContextActive) {
_macOSLocalFocusLost = false;
}
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
} else {
if (_macOSInputActive) {
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
}
if (_isMacOSKeyboardContextActive) {
_macOSLocalFocusLost = true;
}
}
}
// 1. Sync the keyboard grab state with the current context.
// 2. Call enterOrLeave() to update the input state in the FFI layer.
// 3. Request or unfocus the raw key focus node based on the current context.
// Flutter focus and native input are separate; native input activates only
// after the FocusNode has primary focus.
void _syncMacOSKeyboardGrab({
bool reassert = false,
bool allowInactiveLifecycle = false,
}) {
if (!isMacOS) return;
// A secondary engine may stay hidden while its window is visible, so
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
final lifecycleAllowsInput = allowInactiveLifecycle ||
_macOSLifecycleState == null ||
_macOSLifecycleState == AppLifecycleState.resumed;
// Input stays pointer-gated except for focused fullscreen recovery, which
// compensates when macOS omits PointerEnter during a Space switch.
final shouldFocus = lifecycleAllowsInput &&
_isMacOSKeyboardContextActive &&
!_macOSInputSuppressed &&
_blockableOverlayState.middleBlocked.isFalse &&
_cursorOverImage.value &&
!_macOSLocalFocusLost;
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
final shouldActivateInput = shouldFocus && hasFocus;
if (shouldActivateInput != _macOSInputActive ||
(shouldActivateInput && reassert)) {
_ffi.inputModel.enterOrLeave(shouldActivateInput);
}
_macOSInputActive = shouldActivateInput;
if (!shouldFocus) {
_macOSExplicitFocusRequestPending = false;
if (hasFocus) _rawKeyFocusNode.unfocus();
} else if (!hasFocus) {
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
_rawKeyFocusNode.requestFocus();
} else {
_macOSExplicitFocusRequestPending = false;
}
}
void _restoreMacOSKeyboardAfterFullScreen({
required int generation,
bool allowHiddenLifecycle = false,
}) {
// Fullscreen callbacks preserve recovery while hidden. Native window focus
// may bypass a stale hidden lifecycle for the newly visible Space.
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
(!allowHiddenLifecycle &&
_macOSLifecycleState == AppLifecycleState.hidden)) {
return;
}
final contextActive =
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
// macOS can focus a fullscreen Space without sending PointerEnter. Native
// window focus is authoritative here; a later blur cancels this generation
// before an off-screen window can restore input.
final shouldInferPointerInside = !_cursorOverImage.value &&
allowHiddenLifecycle &&
stateGlobal.fullscreen.isTrue &&
contextActive;
final canRestore = contextActive &&
_blockableOverlayState.middleBlocked.isFalse &&
(_cursorOverImage.value || shouldInferPointerInside);
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
if (!canRestore) {
// Consuming recovery here requires a later pointer/window/tab event.
return;
}
if (shouldInferPointerInside) {
_cursorOverImage.value = true;
}
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
}
void _scheduleMacOSKeyboardAfterFullScreen({
required int generation,
bool allowHiddenLifecycle = false,
}) {
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
// completion and then advance one event-loop turn before restoring.
WidgetsBinding.instance.addPostFrameCallback((_) {
Timer.run(() {
if (mounted) {
_restoreMacOSKeyboardAfterFullScreen(
generation: generation,
allowHiddenLifecycle: allowHiddenLifecycle,
);
}
});
});
WidgetsBinding.instance.ensureVisualUpdate();
}
void _queueMacOSKeyboardAfterFullScreen({
bool allowHiddenLifecycle = false,
}) {
final generation = _macOSFullScreenFocusRecovery.queue();
if (_macOSLifecycleState == AppLifecycleState.paused ||
_macOSLifecycleState == AppLifecycleState.detached) {
_macOSFullScreenFocusRecovery.cancel();
return;
}
_scheduleMacOSKeyboardAfterFullScreen(
generation: generation,
allowHiddenLifecycle: allowHiddenLifecycle,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!isMacOS || _macOSLifecycleState == state) return;
_macOSLifecycleState = state;
if (state == AppLifecycleState.resumed) {
_syncMacOSKeyboardGrab(reassert: true);
} else if (_macOSInputActive) {
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
}
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
if (generation == null) return;
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.resumed) {
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
} else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_macOSFullScreenFocusRecovery.cancel();
}
}
@override @override
void onWindowBlur() { void onWindowBlur() {
super.onWindowBlur(); super.onWindowBlur();
// On windows, we use `focus` way to handle keyboard better. // On windows, we use `focus` way to handle keyboard better.
// Now on Linux, there's some rdev issues which will break the input. // Now on Linux, there's some rdev issues which will break the input.
// We disable the `focus` way for non-Windows temporarily. // We disable the `focus` way for Linux temporarily.
if (isWindows) { if (isWindows || isMacOS) {
_isWindowBlur = true; _isWindowBlur = true;
}
if (isMacOS) {
_macOSFullScreenFocusRecovery.cancel();
// A blur or Space switch may not emit PointerExit, so cursor state alone
// cannot prevent the old remote surface from reclaiming the keyboard.
_macOSLocalFocusLost = true;
}
if (isWindows) {
// unfocus the primary-focus when the whole window is lost focus, // unfocus the primary-focus when the whole window is lost focus,
// and let OS to handle events instead. // and let OS to handle events instead.
_rawKeyFocusNode.unfocus(); _rawKeyFocusNode.unfocus();
} }
stateGlobal.isFocused.value = false; stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
// When window loses focus, temporarily release relative mouse mode constraints // When window loses focus, temporarily release relative mouse mode constraints
// to allow user to interact with other applications normally. // to allow user to interact with other applications normally.
@@ -257,16 +499,50 @@ class _RemotePageState extends State<RemotePage>
void onWindowFocus() { void onWindowFocus() {
super.onWindowFocus(); super.onWindowFocus();
// See [onWindowBlur]. // See [onWindowBlur].
if (isWindows) { if (isWindows || isMacOS) {
_isWindowBlur = false; _isWindowBlur = false;
} }
if (isMacOS) stateGlobal.getInputSource(force: true);
stateGlobal.isFocused.value = true; stateGlobal.isFocused.value = true;
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
// fullscreen Space queues delayed recovery; if this window blurs again, the
// pending recovery is cancelled before native input can reactivate.
// Regression: switch directly between fullscreen remote Spaces without
// moving or clicking; only the newly focused session may receive input.
if (isMacOS &&
stateGlobal.fullscreen.isTrue &&
!_ffi.inputModel.relativeMouseMode.value) {
// Native window focus is authoritative when a secondary engine retains a
// stale hidden lifecycle state after its fullscreen Space becomes visible.
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
}
// Refocus without PointerEnter: the cursor already hovers the image when
// focus returns (Alt+Tab, taskbar), so enterView() never fires again.
if (isWindows &&
_cursorOverImage.value &&
_windowsCanFocusRemoteInput &&
!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
// Restore relative mouse mode constraints when window regains focus. // Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) { if (_ffi.inputModel.relativeMouseMode.value) {
_rawKeyFocusNode.requestFocus(); if (isMacOS) {
// Native relative mode retains pointer capture and does not emit
// PointerEnter after window focus returns. Restore both latches unless
// a local overlay still owns input.
if (_blockableOverlayState.middleBlocked.isFalse) {
_cursorOverImage.value = true;
_macOSLocalFocusLost = false;
}
} else if (!isWindows || _windowsCanFocusRemoteInput) {
_rawKeyFocusNode.requestFocus();
}
_ffi.inputModel.onWindowFocus(); _ffi.inputModel.onWindowFocus();
} }
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
} }
@override @override
@@ -327,6 +603,13 @@ class _RemotePageState extends State<RemotePage>
void onWindowMinimize() { void onWindowMinimize() {
super.onWindowMinimize(); super.onWindowMinimize();
WakelockManager.disable(_uniqueKey); WakelockManager.disable(_uniqueKey);
if (isMacOS) {
_macOSFullScreenFocusRecovery.cancel();
_isWindowBlur = true;
_cursorOverImage.value = false;
stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
}
// Release cursor constraints when minimized // Release cursor constraints when minimized
if (_ffi.inputModel.relativeMouseMode.value) { if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur(); _ffi.inputModel.onWindowBlur();
@@ -338,6 +621,7 @@ class _RemotePageState extends State<RemotePage>
super.onWindowEnterFullScreen(); super.onWindowEnterFullScreen();
if (isMacOS) { if (isMacOS) {
stateGlobal.setFullscreen(true); stateGlobal.setFullscreen(true);
_queueMacOSKeyboardAfterFullScreen();
} }
} }
@@ -346,6 +630,7 @@ class _RemotePageState extends State<RemotePage>
super.onWindowLeaveFullScreen(); super.onWindowLeaveFullScreen();
if (isMacOS) { if (isMacOS) {
stateGlobal.setFullscreen(false); stateGlobal.setFullscreen(false);
_queueMacOSKeyboardAfterFullScreen();
} }
} }
@@ -354,6 +639,14 @@ class _RemotePageState extends State<RemotePage>
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true; final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
// https://github.com/flutter/flutter/issues/64935 // https://github.com/flutter/flutter/issues/64935
if (isMacOS) {
// Tab moves release before transfer to avoid a late retained-session leave.
if (closeSession) {
_releaseMacOSRemoteInput();
}
_tabStateSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
}
super.dispose(); super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
@@ -368,8 +661,9 @@ class _RemotePageState extends State<RemotePage>
_ffi.inputModel.onRelativeMouseModeDisabled = null; _ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...). // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession); _ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) { if (closeSession && !isMacOS) {
// ensure we leave this session, this is a double check // ensure we leave this session, this is a double check
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
} }
DesktopMultiWindow.removeListener(this); DesktopMultiWindow.removeListener(this);
@@ -444,6 +738,8 @@ class _RemotePageState extends State<RemotePage>
} else { } else {
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
} }
} else if (isMacOS) {
_onMacOSFocusChange();
} }
}, },
inputModel: _ffi.inputModel, inputModel: _ffi.inputModel,
@@ -549,7 +845,20 @@ class _RemotePageState extends State<RemotePage>
} }
// See [onWindowBlur]. // See [onWindowBlur].
if (!isWindows) { if (isMacOS) {
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
} else if (isWindows) {
// Blur unfocuses this node and nothing restores it, so the keyboard stayed
// dead until a click. Focus only while the window is really active, or a
// background window would grab system keys. onFocusChange does enterOrLeave.
if (!_isWindowBlur &&
_windowsCanFocusRemoteInput &&
!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
} else {
if (!_rawKeyFocusNode.hasFocus) { if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus(); _rawKeyFocusNode.requestFocus();
} }
@@ -575,7 +884,9 @@ class _RemotePageState extends State<RemotePage>
} }
// See [onWindowBlur]. // See [onWindowBlur].
if (!isWindows) { if (isMacOS) {
_syncMacOSKeyboardGrab();
} else if (!isWindows) {
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
} }
} }
@@ -600,17 +911,29 @@ class _RemotePageState extends State<RemotePage>
onEnter: onEnter, onEnter: onEnter,
onExit: onExit, onExit: onExit,
onPointerDown: (event) { onPointerDown: (event) {
// A double check for blur status. // A double check for blur status on Windows and macOS.
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false. // Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
// Sometimes the system does not send the necessary focus event to flutter. We should manually // Sometimes the system does not send the necessary focus event to flutter. We should manually
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can // handle this inconsistent status by setting `_isWindowBlur` to false. So we can
// ensure the grab-key thread is running when our users are clicking the remote canvas. // ensure the grab-key thread is running when our users are clicking the remote canvas.
if (_isWindowBlur) { if ((isWindows || isMacOS) && _isWindowBlur) {
debugPrint( debugPrint(
"Unexpected status: onPointerDown is triggered while the remote window is in blur status"); "Unexpected status: onPointerDown is triggered while the remote window is in blur status");
_isWindowBlur = false; _isWindowBlur = false;
} }
if (!_rawKeyFocusNode.hasFocus) { if (isMacOS) {
// Regions without matching enter/exit callbacks cannot safely own
// keyboard state.
if (onEnter == null || onExit == null) return;
if (!stateGlobal.isFocused.value) {
stateGlobal.isFocused.value = true;
}
_cursorOverImage.value = true;
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
} else if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus(); _rawKeyFocusNode.requestFocus();
} }
}, },

View File

@@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final args = jsonDecode(call.arguments); final args = jsonDecode(call.arguments);
final id = args['id']; final id = args['id'];
final close = args['close']; final close = args['close'];
RemotePage? remotePage;
try { try {
final remotePage = tabController.state.value.tabs remotePage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == id) .firstWhere((tab) => tab.key == id)
.page as RemotePage; .page as RemotePage;
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString(); returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
} catch (e) { } catch (e) {
debugPrint('Failed to get cached session data: $e'); debugPrint('Failed to get cached session data: $e');
} }
if (close && returnValue != null) { if (close && returnValue != null && remotePage != null) {
remotePage.releaseMacOSInputForTabTransfer();
closeSessionOnDispose[id] = false; closeSessionOnDispose[id] = false;
tabController.closeBy(id); tabController.closeBy(id);
} }

View File

@@ -22,6 +22,14 @@ import '../../models/file_model.dart';
import '../../models/platform_model.dart'; import '../../models/platform_model.dart';
import '../../models/server_model.dart'; import '../../models/server_model.dart';
/// Set only by this window's own close control, and only once the user has confirmed. Any other
/// way the window can go - a session logout closing every window, the window manager, a native
/// title-bar button this app does not draw - leaves it false, which is the honest answer:
/// nothing in that close says who asked for it. It lives at file scope because the control that
/// sets it (`ConnectionManagerState`) and the handler that reads it (`_DesktopServerPageState`)
/// are different widgets.
bool _cmClosedByOperator = false;
class DesktopServerPage extends StatefulWidget { class DesktopServerPage extends StatefulWidget {
const DesktopServerPage({Key? key}) : super(key: key); const DesktopServerPage({Key? key}) : super(key: key);
@@ -55,7 +63,10 @@ class _DesktopServerPageState extends State<DesktopServerPage>
@override @override
void onWindowClose() { void onWindowClose() {
Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) { // Other platforms keep the old behaviour exactly: the ambiguity this guards against is a
// Linux session logout, which closes every window in the session.
final byOperator = _cmClosedByOperator || !isLinux;
Future.wait([gFFI.serverModel.closeAll(byOperator: byOperator), gFFI.close()]).then((_) {
if (isMacOS) { if (isMacOS) {
RdPlatformChannel.instance.terminate(); RdPlatformChannel.instance.terminate();
} else { } else {
@@ -327,6 +338,7 @@ class ConnectionManagerState extends State<ConnectionManager>
var tabController = gFFI.serverModel.tabController; var tabController = gFFI.serverModel.tabController;
final connLength = tabController.length; final connLength = tabController.length;
if (connLength <= 1) { if (connLength <= 1) {
_cmClosedByOperator = true;
windowManager.close(); windowManager.close();
return true; return true;
} else { } else {
@@ -338,6 +350,9 @@ class ConnectionManagerState extends State<ConnectionManager>
res = await closeConfirmDialog(); res = await closeConfirmDialog();
} }
if (res) { if (res) {
// After the dialog, never before it: an external close while it is open must not
// inherit an intent the user had not expressed yet.
_cmClosedByOperator = true;
windowManager.close(); windowManager.close();
} }
return res; return res;

View File

@@ -5,7 +5,7 @@ import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:xterm/xterm.dart'; import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
import 'terminal_connection_manager.dart'; import 'terminal_connection_manager.dart';
class TerminalPage extends StatefulWidget { class TerminalPage extends StatefulWidget {
@@ -19,6 +19,8 @@ class TerminalPage extends StatefulWidget {
required this.tabKey, required this.tabKey,
this.forceRelay, this.forceRelay,
this.connToken, this.connToken,
this.onClipboardWriteBlocked,
this.onClipboardWriteSucceeded,
}) : super(key: key); }) : super(key: key);
final String id; final String id;
final String? password; final String? password;
@@ -26,6 +28,8 @@ class TerminalPage extends StatefulWidget {
final bool? forceRelay; final bool? forceRelay;
final bool? isSharedPassword; final bool? isSharedPassword;
final String? connToken; final String? connToken;
final ValueChanged<String>? onClipboardWriteBlocked;
final ValueChanged<String>? onClipboardWriteSucceeded;
final int terminalId; final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction /// Tab key for focus management, passed from parent to avoid duplicate construction
@@ -71,6 +75,8 @@ class _TerminalPageState extends State<TerminalPage>
// Create terminal model with specific terminal ID // Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId); _terminalModel = TerminalModel(_ffi, widget.terminalId);
_terminalModel.onClipboardWriteBlocked = widget.onClipboardWriteBlocked;
_terminalModel.onClipboardWriteSucceeded = widget.onClipboardWriteSucceeded;
debugPrint( debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}'); '[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
@@ -197,7 +203,7 @@ class _TerminalPageState extends State<TerminalPage>
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final heightPx = constraints.maxHeight; final heightPx = constraints.maxHeight;
return TerminalView( return TerminalMouseInteraction(
_terminalModel.terminal, _terminalModel.terminal,
controller: _terminalModel.terminalController, controller: _terminalModel.terminalController,
focusNode: _terminalFocusNode, focusNode: _terminalFocusNode,

View File

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

View File

@@ -127,7 +127,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
WakelockManager.enable(_uniqueKey); WakelockManager.enable(_uniqueKey);
_ffi.ffiModel.updateEventListener(sessionId, widget.id); _ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
_ffi.dialogManager.loadMobileActionsOverlayVisible(); _ffi.dialogManager.loadMobileActionsOverlayVisible();
DesktopMultiWindow.addListener(this); DesktopMultiWindow.addListener(this);

View File

@@ -9,9 +9,6 @@ import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
import 'package:flutter_hbb/plugin/common.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -1336,6 +1333,12 @@ class ScreenAdjustor {
final FFI ffi; final FFI ffi;
final VoidCallback cbExitFullscreen; final VoidCallback cbExitFullscreen;
window_size.Screen? _screen; window_size.Screen? _screen;
Size? _waylandMaximizedWorkAreaSize;
Rect? _waylandWorkAreaScreenFrame;
double? _waylandWorkAreaScaleFactor;
Rect? _x11WorkArea;
Rect? _x11WorkAreaScreenFrame;
double? _x11WorkAreaScaleFactor;
ScreenAdjustor({ ScreenAdjustor({
required this.id, required this.id,
@@ -1346,9 +1349,18 @@ class ScreenAdjustor {
bool get isFullscreen => stateGlobal.fullscreen.isTrue; bool get isFullscreen => stateGlobal.fullscreen.isTrue;
int get windowId => stateGlobal.windowId; int get windowId => stateGlobal.windowId;
Future<bool?> isWindowMaximized() async {
try {
return await WindowController.fromWindowId(windowId).isMaximized();
} catch (_) {
// The delayed resolution callback may run after the window is disposed.
return null;
}
}
adjustWindow(BuildContext context) { adjustWindow(BuildContext context) {
return futureBuilder( return futureBuilder(
future: isWindowCanBeAdjusted(), future: isWindowCanBeAdjusted(context),
hasData: (data) { hasData: (data) {
final visible = data as bool; final visible = data as bool;
if (!visible) return Offstage(); if (!visible) return Offstage();
@@ -1364,36 +1376,201 @@ class ScreenAdjustor {
}); });
} }
doAdjustWindow(BuildContext context) async { // Linux screen and work-area coordinates can use different units or become
await updateScreen(); // unreliable across Wayland/X11 state changes, so normalize reported frames
if (_screen != null) { // and cache usable work-area measurements before sizing the window.
cbExitFullscreen();
double scale = _screen!.scaleFactor; Future<void> _updateLinuxWorkAreaCache({
final wndRect = await WindowController.fromWindowId(windowId).getFrame(); required window_size.Screen screen,
final mediaSize = MediaQueryData.fromView(View.of(context)).size; required Rect wndRect,
// On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect. required bool isWayland,
required bool isX11,
required bool forMenu,
}) async {
if (isWayland &&
(_waylandWorkAreaScreenFrame != screen.frame ||
_waylandWorkAreaScaleFactor != screen.scaleFactor)) {
_waylandMaximizedWorkAreaSize = null;
_waylandWorkAreaScreenFrame = screen.frame;
_waylandWorkAreaScaleFactor = screen.scaleFactor;
}
if (isWayland &&
forMenu &&
!isFullscreen &&
await isWindowMaximized() == true) {
_waylandMaximizedWorkAreaSize = wndRect.size;
}
if (isX11 &&
(_x11WorkAreaScreenFrame != screen.frame ||
_x11WorkAreaScaleFactor != screen.scaleFactor)) {
_x11WorkArea = null;
_x11WorkAreaScreenFrame = screen.frame;
_x11WorkAreaScaleFactor = screen.scaleFactor;
}
if (isX11 && forMenu && !isFullscreen) {
_x11WorkArea = screen.visibleFrame;
}
}
Future<Rect?> _getEffectiveScreenFrame({
required window_size.Screen screen,
required bool isWayland,
required bool isX11,
required bool forMenu,
}) async {
Rect frameRect = screen.visibleFrame;
if (isMacOS && forMenu && isFullscreen) {
List<double>? workArea;
try {
workArea = await kMacOSPermChannel
.invokeListMethod<double>('getMacOSWorkAreaSize');
} catch (_) {
return null;
}
if (workArea == null || workArea.length != 2) {
return null;
}
frameRect = Rect.fromLTWH(
frameRect.left,
frameRect.top,
workArea[0] < frameRect.width ? workArea[0] : frameRect.width,
workArea[1] < frameRect.height ? workArea[1] : frameRect.height,
);
}
final x11WorkArea = _x11WorkArea;
if (isX11 &&
forMenu &&
isFullscreen &&
x11WorkArea != null &&
(x11WorkArea.width < frameRect.width ||
x11WorkArea.height < frameRect.height)) {
frameRect = x11WorkArea;
}
final screenScale = screen.scaleFactor;
if (isWayland && screenScale > 1.01) {
String monitorLayoutMode;
try {
monitorLayoutMode =
await bind.mainGetCommon(key: 'gnome-monitor-layout-mode');
} catch (_) {
monitorLayoutMode = '';
}
if (monitorLayoutMode == 'physical') {
frameRect = Rect.fromLTRB(
frameRect.left / screenScale,
frameRect.top / screenScale,
frameRect.right / screenScale,
frameRect.bottom / screenScale,
);
}
}
return frameRect;
}
Future<Rect?> _getAdjustedWindowFrame(Size mediaSize,
{bool forMenu = false}) async {
final screen = _screen;
if (screen != null) {
// Windows window frames use physical pixels while Flutter view sizes are
// logical. macOS and Linux window frames use the same units as Flutter.
double scale = isWindows ? screen.scaleFactor : 1.0;
final Rect wndRect;
try {
wndRect = await WindowController.fromWindowId(windowId).getFrame();
} catch (e) {
debugPrint("Failed to get frame of window $windowId, it may be hidden");
return null;
}
// On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect.
// https://stackoverflow.com/a/7561083 // https://stackoverflow.com/a/7561083
double magicWidth = double magicWidth =
wndRect.right - wndRect.left - mediaSize.width * scale; wndRect.right - wndRect.left - mediaSize.width * scale;
double magicHeight = double magicHeight =
wndRect.bottom - wndRect.top - mediaSize.height * scale; wndRect.bottom - wndRect.top - mediaSize.height * scale;
final canvasModel = ffi.canvasModel; final canvasModel = ffi.canvasModel;
// canvasModel.scale is the rendered scale and already applies kIgnoreDpi.
// Use it instead of the remote source resolution.
final isWayland = isLinux && bind.mainCurrentIsWayland();
final isX11 = isLinux && !isWayland;
await _updateLinuxWorkAreaCache(
screen: screen,
wndRect: wndRect,
isWayland: isWayland,
isX11: isX11,
forMenu: forMenu,
);
if (isWindows && forMenu && isFullscreen) {
// desktop_multi_window's hidden title bar keeps 8 physical pixels on
// each horizontal edge and at the bottom, plus up to 1px at the top.
// Fullscreen removes these in WM_NCCALCSIZE, so predict the restored
// frame's worst-case padding when deciding whether to show the menu.
magicWidth = 16.0;
magicHeight = 9.0;
}
double horizontalEdges;
double verticalEdges;
if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) {
// Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen
// before resizing. Predict the restored normal-window edges when
// deciding whether to show the menu item.
final resizePadding = isLinux && !kUseCompatibleUiMode
? kDragToResizeAreaPaddingSize
: 0.0;
final windowEdge = kWindowBorderWidth + resizePadding;
horizontalEdges = windowEdge * 2;
verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2;
} else {
horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge;
verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge;
}
final width = (canvasModel.getDisplayWidth() * canvasModel.scale + final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
CanvasModel.leftToEdge + horizontalEdges) *
CanvasModel.rightToEdge) *
scale + scale +
magicWidth; magicWidth;
final height = (canvasModel.getDisplayHeight() * canvasModel.scale + final height =
CanvasModel.topToEdge + (canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) *
CanvasModel.bottomToEdge) * scale +
scale + magicHeight;
magicHeight;
double left = wndRect.left + (wndRect.width - width) / 2; double left = wndRect.left + (wndRect.width - width) / 2;
double top = wndRect.top + (wndRect.height - height) / 2; double top = wndRect.top + (wndRect.height - height) / 2;
Rect frameRect = _screen!.frame; final frameRect = await _getEffectiveScreenFrame(
if (!isFullscreen) { screen: screen,
frameRect = _screen!.visibleFrame; isWayland: isWayland,
isX11: isX11,
forMenu: forMenu,
);
if (frameRect == null) {
return null;
}
var availableSize = frameRect.size;
if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) {
final cachedSize = _waylandMaximizedWorkAreaSize!;
availableSize = Size(
cachedSize.width < availableSize.width
? cachedSize.width
: availableSize.width,
cachedSize.height < availableSize.height
? cachedSize.height
: availableSize.height,
);
}
// A window frame cannot be smaller than its client area. Tolerate small
// floating-point differences; larger negative values mean the native
// frame and Flutter view metrics are not synchronized.
if (magicWidth < -0.1 || magicHeight < -0.1) {
return null;
}
// Reject implausibly small targets to avoid hiding the window.
if (width < 300 || height < 300) {
return null;
}
// The remote size may change after the menu is built. Reject targets
// that exceed the available area.
final exceedsScreen =
width > availableSize.width || height > availableSize.height;
if (exceedsScreen) {
return null;
} }
if (left < frameRect.left) { if (left < frameRect.left) {
left = frameRect.left; left = frameRect.left;
@@ -1407,69 +1584,101 @@ class ScreenAdjustor {
if ((top + height) > frameRect.bottom) { if ((top + height) > frameRect.bottom) {
top = frameRect.bottom - height; top = frameRect.bottom - height;
} }
await WindowController.fromWindowId(windowId) return Rect.fromLTWH(left, top, width, height);
.setFrame(Rect.fromLTWH(left, top, width, height)); }
return null;
}
doAdjustWindow([BuildContext? context]) async {
// A resolution change is adjusted after a delay, when the menu context may
// already be disposed. Each desktop_multi_window window has its own engine,
// so that engine's first view is the current window.
final views = WidgetsBinding.instance.platformDispatcher.views;
if (context == null && views.isEmpty) {
return;
}
final view = context != null ? View.of(context) : views.first;
await updateScreen();
if (_screen != null) {
final wc = WindowController.fromWindowId(windowId);
final wasFullscreen = isFullscreen;
cbExitFullscreen();
if (wasFullscreen) {
// Wait for the native fullscreen exit to update the window frame.
await Future.delayed(Duration(milliseconds: 700));
await updateScreen();
}
if (isLinux) {
final isMaximized = await isWindowMaximized();
if (isMaximized == null) {
return;
}
if (isMaximized == true) {
// setFrame may be ignored while the native window is maximized.
try {
await wc.unmaximize();
} catch (_) {
return;
}
stateGlobal.setMaximized(false);
// Wait for the window manager and Flutter view metrics to reflect
// the restored window before calculating and setting its frame.
await Future.delayed(Duration(milliseconds: 300));
await updateScreen();
}
}
final mediaSize = MediaQueryData.fromView(view).size;
final frame = await _getAdjustedWindowFrame(mediaSize);
if (frame == null) {
return;
}
try {
await wc.setFrame(frame);
} catch (_) {
return;
}
stateGlobal.setMaximized(false); stateGlobal.setMaximized(false);
} }
} }
updateScreen() async { updateScreen() async {
final String info = _screen = await _getCurrentScreen();
isWeb ? screenInfo : await _getScreenInfoDesktop() ?? ''; }
if (info.isEmpty) {
_screen = null; Future<window_size.Screen?> _getCurrentScreen() async {
} else { try {
final screenMap = jsonDecode(info); return (await window_size.getWindowInfo()).screen;
_screen = window_size.Screen( } catch (e) {
Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'], debugPrint('Failed to get current window screen: $e');
screenMap['frame']['r'], screenMap['frame']['b']), return null;
Rect.fromLTRB(
screenMap['visibleFrame']['l'],
screenMap['visibleFrame']['t'],
screenMap['visibleFrame']['r'],
screenMap['visibleFrame']['b']),
screenMap['scaleFactor']);
} }
} }
_getScreenInfoDesktop() async { Future<bool> isWindowCanBeAdjusted([BuildContext? context]) async {
final v = await rustDeskWinManager.call( if (isWeb) {
WindowType.Main, kWindowGetWindowInfo, ''); return false;
return v.result; }
} // Capture the view before awaiting because the menu context may be disposed.
final views = WidgetsBinding.instance.platformDispatcher.views;
Future<bool> isWindowCanBeAdjusted() async { if (context == null && views.isEmpty) {
return false;
}
final view = context != null ? View.of(context) : views.first;
final mediaSize = MediaQueryData.fromView(view).size;
final viewStyle = final viewStyle =
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
if (viewStyle != kRemoteViewStyleOriginal) { if (viewStyle != kRemoteViewStyleOriginal) {
return false; return false;
} }
if (!isWeb) { final remoteCount = RemoteCountState.find().value;
final remoteCount = RemoteCountState.find().value; if (remoteCount != 1) {
if (remoteCount != 1) { return false;
return false;
}
} }
await updateScreen();
if (_screen == null) { if (_screen == null) {
return false; return false;
} }
final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor; return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null;
double selfWidth = _screen!.visibleFrame.width;
double selfHeight = _screen!.visibleFrame.height;
if (isFullscreen) {
selfWidth = _screen!.frame.width;
selfHeight = _screen!.frame.height;
}
final canvasModel = ffi.canvasModel;
final displayWidth = canvasModel.getDisplayWidth();
final displayHeight = canvasModel.getDisplayHeight();
final requiredWidth =
CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge;
final requiredHeight =
CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge;
return selfWidth > (requiredWidth * scale) &&
selfHeight > (requiredHeight * scale);
} }
} }
@@ -1478,20 +1687,11 @@ class _DisplayMenu extends StatefulWidget {
final FFI ffi; final FFI ffi;
final ToolbarState state; final ToolbarState state;
final Function(bool) setFullscreen; final Function(bool) setFullscreen;
final Widget pluginItem; const _DisplayMenu(
_DisplayMenu( {required this.id,
{Key? key,
required this.id,
required this.ffi, required this.ffi,
required this.state, required this.state,
required this.setFullscreen}) required this.setFullscreen});
: pluginItem = LocationItem.createLocationItem(
id,
ffi,
kLocationClientRemoteToolbarDisplay,
true,
),
super(key: key);
@override @override
State<_DisplayMenu> createState() => _DisplayMenuState(); State<_DisplayMenu> createState() => _DisplayMenuState();
@@ -1529,7 +1729,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
_screenAdjustor.updateScreen();
menuChildrenGetter(_IconSubmenuButtonState state) { menuChildrenGetter(_IconSubmenuButtonState state) {
final menuChildren = <Widget>[ final menuChildren = <Widget>[
_screenAdjustor.adjustWindow(context), _screenAdjustor.adjustWindow(context),
@@ -1582,9 +1781,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
]); ]);
} }
} }
if (ffi.connType == ConnType.defaultConn) {
menuChildren.add(widget.pluginItem);
}
return menuChildren; return menuChildren;
} }
@@ -2096,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
Future<void> _getLocalResolutionWayland() async { Future<void> _getLocalResolutionWayland() async {
if (!isWayland) return _getLocalResolution(); if (!isWayland) return _getLocalResolution();
final window = await window_size.getWindowInfo(); try {
final screen = window.screen; final window = await window_size.getWindowInfo();
if (screen != null) { final screen = window.screen;
setState(() { if (screen != null) {
_localResolution = Resolution( setState(() {
screen.frame.width.toInt(), _localResolution = Resolution(
screen.frame.height.toInt(), screen.frame.width.toInt(),
); screen.frame.height.toInt(),
}); );
});
}
} catch (e) {
debugPrint('Failed to get local resolution on Wayland: $e');
} }
} }
@@ -2176,8 +2376,16 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
return; return;
} }
if (w == rect.width.toInt() && h == rect.height.toInt()) { if (w == rect.width.toInt() && h == rect.height.toInt()) {
if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { if (!await widget.screenAdjustor.isWindowCanBeAdjusted()) {
widget.screenAdjustor.doAdjustWindow(context); return;
}
if (widget.screenAdjustor.isFullscreen) {
return;
}
if ((await widget.screenAdjustor.isWindowMaximized()) == false) {
// This delayed callback can outlive the menu State, so its context
// is unsafe.
widget.screenAdjustor.doAdjustWindow();
} }
} }
}); });
@@ -2484,6 +2692,8 @@ class _KeyboardMenu extends StatelessWidget {
? (v) async { ? (v) async {
if (v != null) { if (v != null) {
await stateGlobal.setInputSource(ffi.sessionId, v); await stateGlobal.setInputSource(ffi.sessionId, v);
// Release native input; see the macOS trade-offs in RemotePage.
if (isMacOS) ffi.inputModel.enterOrLeave(false);
await ffi.ffiModel.checkDesktopKeyboardMode(); await ffi.ffiModel.checkDesktopKeyboardMode();
await ffi.inputModel.updateKeyboardMode(); await ffi.inputModel.updateKeyboardMode();
} }
@@ -2740,7 +2950,9 @@ class _RecordMenu extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var ffi = Provider.of<FfiModel>(context); var ffi = Provider.of<FfiModel>(context);
var recordingModel = Provider.of<RecordingModel>(context); var recordingModel = Provider.of<RecordingModel>(context);
final visible = final hideRecordingButton =
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
final visible = !hideRecordingButton &&
(recordingModel.start || ffi.permissions['recording'] != false); (recordingModel.start || ffi.permissions['recording'] != false);
if (!visible) return Offstage(); if (!visible) return Offstage();
return _IconMenuButton( return _IconMenuButton(

View File

@@ -30,9 +30,6 @@ import 'mobile/pages/server_page.dart';
import 'mobile/widgets/deploy_dialog.dart'; import 'mobile/widgets/deploy_dialog.dart';
import 'models/platform_model.dart'; import 'models/platform_model.dart';
import 'package:flutter_hbb/plugin/handlers.dart'
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
/// Basic window and launch properties. /// Basic window and launch properties.
int? kWindowId; int? kWindowId;
WindowType? kWindowType; WindowType? kWindowType;
@@ -141,8 +138,6 @@ void runMainApp(bool startService) async {
await bind.mainCheckConnectStatus(); await bind.mainCheckConnectStatus();
if (startService) { if (startService) {
gFFI.serverModel.startService(); gFFI.serverModel.startService();
bind.pluginSyncUi(syncTo: kAppTypeMain);
bind.pluginListReload();
} }
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]); await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
gFFI.userModel.refreshCurrentUser(); gFFI.userModel.refreshCurrentUser();
@@ -570,12 +565,6 @@ _registerEventHandler() {
reloadAllWindows(); reloadAllWindows();
}); });
} }
// Register native handlers.
if (isDesktop) {
platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async {
NativeUiHandler.instance.onEvent(evt);
});
}
if (isAndroid) { if (isAndroid) {
platformFFI.registerEventHandler( platformFFI.registerEventHandler(
'android_needs_deploy', 'android_needs_deploy', (_) async { 'android_needs_deploy', 'android_needs_deploy', (_) async {
@@ -588,7 +577,8 @@ _registerEventHandler() {
Widget keyListenerBuilder(BuildContext context, Widget? child) { Widget keyListenerBuilder(BuildContext context, Widget? child) {
return RawKeyboardListener( return RawKeyboardListener(
focusNode: FocusNode(), // `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4"
focusNode: FocusNode(skipTraversal: isWeb),
child: child ?? Container(), child: child ?? Container(),
onKey: (RawKeyEvent event) { onKey: (RawKeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) { if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
import '../../common.dart'; import '../../common.dart';
import '../../common/widgets/dialog.dart'; import '../../common/widgets/dialog.dart';
import '../../consts.dart';
class FileManagerPage extends StatefulWidget { class FileManagerPage extends StatefulWidget {
FileManagerPage( FileManagerPage(
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
DirectoryOptions get currentOptions => currentFileController.options.value; DirectoryOptions get currentOptions => currentFileController.options.value;
final _uniqueKey = UniqueKey(); final _uniqueKey = UniqueKey();
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
gFFI.ffiModel.beginAndroidDocumentPicker();
try {
return await action();
} finally {
gFFI.ffiModel.endAndroidDocumentPicker();
}
}
Future<void> _importFiles() async {
var imported = 0;
var failed = false;
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final selectedFiles = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<List<dynamic>>(
AndroidChannel.kPickImportFiles));
if (selectedFiles == null || selectedFiles.isEmpty) return;
for (final selected in selectedFiles) {
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
final selectedName = selected['name'] as String?;
final name = selectedName?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
!PathUtil.validName(name, importIsWindows)) {
failed = true;
continue;
}
final destination =
PathUtil.join(importDirectory, name, importIsWindows);
var overwrite = false;
if (await File(destination).exists()) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult == false) break;
if (overwriteResult != true) continue;
overwrite = true;
}
try {
final success = await gFFI.invokeMethod(
AndroidChannel.kImportFile,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
imported++;
} else {
failed = true;
}
} catch (e) {
failed = true;
debugPrint('Failed to import $name: $e');
}
}
} catch (e) {
failed = true;
debugPrint('Failed to select files for import: $e');
}
await importController.refresh();
if (failed) {
showToast(translate('Failed'));
} else if (imported > 0) {
showToast(translate('Successful'));
}
}
Future<void> _exportFile(Entry entry) async {
try {
final exported = await _runAndroidDocumentPicker(() => gFFI
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
if (exported == true) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export ${entry.name}: $e');
showToast(translate('Failed'));
}
}
Future<void> _importFolder() async {
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final picked = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kPickImportDirectory));
if (picked == null || picked.isEmpty) return;
final uri = picked['uri'] as String?;
final name =
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
name == '.' ||
name == '..' ||
!PathUtil.validName(name, importIsWindows)) {
showToast(translate('Failed'));
return;
}
final destination = PathUtil.join(importDirectory, name, importIsWindows);
final destinationType = await FileSystemEntity.type(destination);
var overwrite = false;
if (destinationType == FileSystemEntityType.directory) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult != true) return;
overwrite = true;
} else if (destinationType != FileSystemEntityType.notFound) {
showToast(translate('Failed'));
return;
}
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
showToast(translate('Successful'));
} else {
showToast(translate('Failed'));
}
} catch (e) {
debugPrint('Failed to import folder: $e');
showToast(translate('Failed'));
}
await importController.refresh();
}
Future<void> _exportItems(SelectedItems items) async {
await _exportPaths(items.items.map((e) => e.path));
}
Future<void> _exportLogs() async {
final home = currentFileController.homePath;
if (home.isEmpty) {
showToast(translate('Failed'));
return;
}
final appDir = PathUtil.join(home, appName, false);
final paths = [
PathUtil.join(appDir, 'Logs', false),
PathUtil.join(appDir, 'ScreenRecord', false),
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
if (paths.isEmpty) {
showToast(translate('Failed'));
return;
}
await _exportPaths(paths);
}
Future<void> _exportPaths(Iterable<String> paths) async {
try {
final result = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
if (result == null) return;
final exported = result['exported'] as int? ?? 0;
final failed = result['failed'] as int? ?? 0;
if (failed > 0) {
showToast(translate('Failed'));
} else if (exported > 0) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export paths: $e');
showToast(translate('Failed'));
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
), ),
value: "refresh", value: "refresh",
), ),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import",
child: Row(
children: [
Icon(Icons.add_to_drive,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Add"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import_folder",
child: Row(
children: [
Icon(Icons.create_new_folder_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Import Folder"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "export_logs",
child: Row(
children: [
Icon(Icons.article_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Export Logs"))
],
),
),
PopupMenuItem( PopupMenuItem(
enabled: currentDir.path != "/", enabled: currentDir.path != "/",
child: Row( child: Row(
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
onSelected: (v) { onSelected: (v) {
if (v == "refresh") { if (v == "refresh") {
currentFileController.refresh(); currentFileController.refresh();
} else if (v == "import") {
_importFiles();
} else if (v == "import_folder") {
_importFolder();
} else if (v == "export_logs") {
_exportLogs();
} else if (v == "select") { } else if (v == "select") {
model.localController.selectedItems.clear(); model.localController.selectedItems.clear();
model.remoteController.selectedItems.clear(); model.remoteController.selectedItems.clear();
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
setState(() {}); setState(() {});
}, },
actions: [ actions: [
if (isAndroid &&
selectedItems?.isLocal == true &&
selectedItems?.items.isNotEmpty == true) ...[
if (selectedItems!.items.length == 1 &&
selectedItems!.items.single.isFile)
IconButton(
tooltip: translate("Save as"),
icon: Icon(Icons.save_alt),
onPressed: () =>
_exportFile(selectedItems!.items.single),
)
else
IconButton(
tooltip: translate("Export"),
icon: Icon(Icons.drive_folder_upload),
onPressed: () => _exportItems(selectedItems!),
),
],
IconButton( IconButton(
icon: Icon(Icons.compare_arrows), icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal), onPressed: () => setState(() => showLocal = !showLocal),
@@ -366,8 +598,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
return BottomSheetBody( return BottomSheetBody(
leading: CircularProgressIndicator(), leading: CircularProgressIndicator(),
title: translate("Waiting"), title: translate("Waiting"),
text: text: "${readableFileSize(activeJob.speed)}/s",
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
onCanceled: () { onCanceled: () {
model.jobController.cancelJob(activeJob.id); model.jobController.cancelJob(activeJob.id);
jobTable.clear(); jobTable.clear();

View File

@@ -1276,6 +1276,14 @@ void showOptions(
List<TToggleMenu> cursorToggles = await toolbarCursor(context, id, gFFI); List<TToggleMenu> cursorToggles = await toolbarCursor(context, id, gFFI);
List<TToggleMenu> displayToggles = List<TToggleMenu> displayToggles =
await toolbarDisplayToggle(context, id, gFFI); await toolbarDisplayToggle(context, id, gFFI);
if (isMobile) {
displayToggles.insert(
0,
TToggleMenu(
child: Text(translate('Lock canvas')),
value: gFFI.canvasModel.locked,
onChanged: (value) => gFFI.canvasModel.setLocked(value == true)));
}
List<TToggleMenu> privacyModeList = []; List<TToggleMenu> privacyModeList = [];
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) || if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||

View File

@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
void checkService() async { void checkService() async {
gFFI.invokeMethod("check_service"); gFFI.invokeMethod("check_service");
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
AndroidPermissionManager.complete(kManageExternalStorage,
await AndroidPermissionManager.check(kManageExternalStorage));
debugPrint("file permission finished");
}
} }
class ServiceNotRunningNotification extends StatelessWidget { class ServiceNotRunningNotification extends StatelessWidget {

View File

@@ -78,6 +78,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _enableAbr = false; var _enableAbr = false;
var _denyLANDiscovery = false; var _denyLANDiscovery = false;
var _onlyWhiteList = false; var _onlyWhiteList = false;
var _onlyIdWhiteList = false;
var _enableDirectIPAccess = false; var _enableDirectIPAccess = false;
var _enableRecordSession = false; var _enableRecordSession = false;
var _enableHardwareCodec = false; var _enableHardwareCodec = false;
@@ -89,16 +90,19 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _directAccessPort = ""; var _directAccessPort = "";
var _fingerprint = ""; var _fingerprint = "";
var _buildDate = ""; var _buildDate = "";
var _myId = "";
var _autoDisconnectTimeout = ""; var _autoDisconnectTimeout = "";
var _hideServer = false; var _hideServer = false;
var _hideProxy = false; var _hideProxy = false;
var _hideNetwork = false; var _hideNetwork = false;
var _hideWebSocket = false; var _hideWebSocket = false;
var _enableTrustedDevices = false; var _enableTrustedDevices = false;
var _enableTcpPunch = false;
var _enableUdpPunch = false; var _enableUdpPunch = false;
var _allowInsecureTlsFallback = false; var _allowInsecureTlsFallback = false;
var _disableUdp = false; var _disableUdp = false;
var _enableIpv6Punch = false; var _enableIpv6Punch = false;
var _enableWebrtc = false;
var _isUsingPublicServer = false; var _isUsingPublicServer = false;
var _allowAskForNoteAtEndOfConnection = false; var _allowAskForNoteAtEndOfConnection = false;
var _preventSleepWhileConnected = true; var _preventSleepWhileConnected = true;
@@ -109,6 +113,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery, _denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery,
bind.mainGetOptionSync(key: kOptionEnableLanDiscovery)); bind.mainGetOptionSync(key: kOptionEnableLanDiscovery));
_onlyWhiteList = whitelistNotEmpty(); _onlyWhiteList = whitelistNotEmpty();
_onlyIdWhiteList = idWhitelistNotEmpty();
_enableDirectIPAccess = option2bool( _enableDirectIPAccess = option2bool(
kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer)); kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer));
_enableRecordSession = option2bool(kOptionEnableRecordSession, _enableRecordSession = option2bool(kOptionEnableRecordSession,
@@ -138,8 +143,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y' || bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y' ||
isWeb; isWeb;
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices); _enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
_enableTcpPunch = mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch); _enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch); _enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
_enableWebrtc = mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
_allowAskForNoteAtEndOfConnection = _allowAskForNoteAtEndOfConnection =
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection); mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
_preventSleepWhileConnected = _preventSleepWhileConnected =
@@ -217,6 +224,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_buildDate = buildDate; _buildDate = buildDate;
} }
final myId = await bind.mainGetMyId();
if (_myId != myId) {
update = true;
_myId = myId;
}
final isUsingPublicServer = await bind.mainIsUsingPublicServer(); final isUsingPublicServer = await bind.mainIsUsingPublicServer();
if (_isUsingPublicServer != isUsingPublicServer) { if (_isUsingPublicServer != isUsingPublicServer) {
update = true; update = true;
@@ -400,6 +413,29 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
changeWhiteList(callback: update); changeWhiteList(callback: update);
}, },
), ),
SettingsTile.switchTile(
title: Row(children: [
Expanded(child: Text(translate('Use ID whitelisting'))),
Offstage(
offstage: !_onlyIdWhiteList,
child: const Icon(Icons.warning_amber_rounded,
color: Color.fromARGB(255, 255, 204, 0)))
.marginOnly(left: 5)
]),
initialValue: _onlyIdWhiteList,
onToggle: (_) async {
update() async {
final onlyIdWhiteList = idWhitelistNotEmpty();
if (onlyIdWhiteList != _onlyIdWhiteList) {
setState(() {
_onlyIdWhiteList = onlyIdWhiteList;
});
}
}
changeIdWhiteList(callback: update);
},
),
SettingsTile.switchTile( SettingsTile.switchTile(
title: Text(translate('Adaptive bitrate')), title: Text(translate('Adaptive bitrate')),
initialValue: _enableAbr, initialValue: _enableAbr,
@@ -783,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) if (!incomingOnly)
SettingsTile.switchTile( SettingsTile.switchTile(
title: Text(translate('Enable UDP hole punching')), title: Text(translate('Enable UDP hole punching')),
initialValue: _enableUdpPunch, initialValue: _enableUdpPunch,
onToggle: (v) async { onToggle: isOptionFixed(kOptionEnableUdpPunch)
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v); ? null
final newValue = : (v) async {
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch); await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
setState(() { final newValue =
_enableUdpPunch = newValue; mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
}); setState(() {
}, _enableUdpPunch = newValue;
});
},
), ),
if (!incomingOnly) if (!incomingOnly)
SettingsTile.switchTile( SettingsTile.switchTile(
title: Text(translate('Enable IPv6 P2P connection')), title: Text(translate('Enable IPv6 P2P connection')),
initialValue: _enableIpv6Punch, initialValue: _enableIpv6Punch,
onToggle: (v) async { onToggle: isOptionFixed(kOptionEnableIpv6Punch)
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v); ? null
final newValue = : (v) async {
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch); await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
setState(() { final newValue =
_enableIpv6Punch = 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( SettingsTile(
title: Text(translate('Language')), title: Text(translate('Language')),
@@ -982,6 +1052,14 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
child: Text(_fingerprint), child: Text(_fingerprint),
), ),
leading: Icon(Icons.fingerprint)), leading: Icon(Icons.fingerprint)),
SettingsTile(
onPressed: (context) => onCopyId(_myId),
title: Text(translate("ID")),
value: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(_myId),
),
leading: Icon(Icons.perm_identity)),
SettingsTile( SettingsTile(
title: Text(translate("Privacy Statement")), title: Text(translate("Privacy Statement")),
onPressed: (context) => onPressed: (context) =>
@@ -1229,16 +1307,18 @@ class __DisplayPageState extends State<_DisplayPage> {
} }
SettingsTile otherRow(String label, String key) { SettingsTile otherRow(String label, String key) {
final value = bind.mainGetUserDefaultOption(key: key) == 'Y'; final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOptionFixed(key); final isOptFixed = isOtherDefaultSettingReadOnly(key);
return SettingsTile.switchTile( return SettingsTile.switchTile(
initialValue: value, initialValue: value,
title: Text(translate(label)), title: Text(translate(label)),
onToggle: isOptFixed onToggle: isOptFixed
? null ? null
: (b) async { : (b) async {
await bind.mainSetUserDefaultOption( await setOtherDefaultSettingOption(
key: key, value: b ? 'Y' : defaultOptionNo); key,
b ? 'Y' : defaultOptionNo,
);
setState(() {}); setState(() {});
}, },
); );

View File

@@ -1,17 +1,68 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/models/input_modifier_utils.dart';
import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:flutter_hbb/web/dummy.dart'
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart'; import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart'; import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart'; import '../../consts.dart';
const _terminalBackgroundOpacity = 0.7;
Widget _buildTerminalViewForPlatform({
required bool reportMouseInput,
required bool reportTouchInput,
required Terminal terminal,
required TerminalController controller,
required TerminalStyle textStyle,
required EdgeInsets padding,
required bool deleteDetection,
required Map<ShortcutActivator, Intent>? shortcuts,
required FocusOnKeyEventCallback onKeyEvent,
required void Function(TapDownDetails, CellOffset) onSecondaryTapDown,
}) {
if (reportMouseInput || reportTouchInput) {
return TerminalMouseInteraction(
terminal,
controller: controller,
autofocus: true,
textStyle: textStyle,
deleteDetection: deleteDetection,
reportTouchInput: reportTouchInput,
shortcuts: shortcuts,
onKeyEvent: onKeyEvent,
backgroundOpacity: _terminalBackgroundOpacity,
padding: padding,
onSecondaryTapDown: onSecondaryTapDown,
);
}
return TerminalView(
terminal,
controller: controller,
autofocus: true,
textStyle: textStyle,
deleteDetection: deleteDetection,
shortcuts: shortcuts,
onKeyEvent: onKeyEvent,
backgroundOpacity: _terminalBackgroundOpacity,
padding: padding,
onSecondaryTapDown: onSecondaryTapDown,
);
}
class TerminalPage extends StatefulWidget { class TerminalPage extends StatefulWidget {
const TerminalPage({ const TerminalPage({
Key? key, Key? key,
@@ -34,6 +85,19 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State<TerminalPage> class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
bool get _canConfigureTerminalClipboardPermission =>
canConfigureTerminalClipboardPermission(
settingsDisabled: bind.isDisableSettings(),
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
);
bool get _canHandleTerminalClipboardWriteRequest =>
canHandleTerminalClipboardWriteRequest(
localOption: bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
),
canConfigurePermission: _canConfigureTerminalClipboardPermission,
);
late FFI _ffi; late FFI _ffi;
late TerminalModel _terminalModel; late TerminalModel _terminalModel;
double? _cellHeight; double? _cellHeight;
@@ -42,9 +106,17 @@ class _TerminalPageState extends State<TerminalPage>
final GlobalKey _keyboardKey = GlobalKey(); final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0; double _keyboardHeight = 0;
late bool _showTerminalExtraKeys; late bool _showTerminalExtraKeys;
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
bool _ctrlLocked = false;
bool _altLocked = false;
// Row3 expand/collapse state for compact keyboard layout
bool _row3Expanded = false;
// For iOS edge swipe gesture // For iOS edge swipe gesture
double _swipeStartX = 0; double _swipeStartX = 0;
double _swipeCurrentX = 0; double _swipeCurrentX = 0;
ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>?
_terminalClipboardNoticeController;
final _terminalClipboardNotice = TerminalClipboardNoticeCoordinator<int>();
// For web only. // For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety. // 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
@@ -59,6 +131,10 @@ class _TerminalPageState extends State<TerminalPage>
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
if (isWeb) {
loadLocalTerminalFontIfNeeded();
}
debugPrint( debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}'); '[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
@@ -73,6 +149,12 @@ class _TerminalPageState extends State<TerminalPage>
// Create terminal model with specific terminal ID // Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId); _terminalModel = TerminalModel(_ffi, widget.terminalId);
if (_canHandleTerminalClipboardWriteRequest) {
_terminalModel.onClipboardWriteBlocked =
_handleTerminalClipboardWriteBlocked;
_terminalModel.onClipboardWriteSucceeded =
_handleTerminalClipboardWriteSucceeded;
}
debugPrint( debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}'); '[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
@@ -94,6 +176,18 @@ class _TerminalPageState extends State<TerminalPage>
// terminal extra keys bar is unnecessary and disabled. // terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop && _showTerminalExtraKeys = !isWebDesktop &&
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
_terminalModel.isCtrlLocked = () => _ctrlLocked;
_terminalModel.clearCtrlLock = () {
if (_ctrlLocked) setState(() => _ctrlLocked = false);
};
_terminalModel.isAltLocked = () => _altLocked;
_terminalModel.clearAltLock = () {
if (_altLocked) setState(() => _altLocked = false);
};
// Load Row3 expand/collapse state from persistent storage. The raw option
// read keeps Row3 collapsed when no value has been saved yet.
_row3Expanded =
bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y';
// Initialize terminal connection // Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager _ffi.dialogManager
@@ -106,12 +200,144 @@ class _TerminalPageState extends State<TerminalPage>
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id); _ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
} }
void _handleTerminalClipboardWriteBlocked(String clipboardText) {
if (!mounted) return;
final option = bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
);
final request = _terminalClipboardNotice.recordBlocked(
source: widget.terminalId,
text: clipboardText,
option: option,
canWrite: (_) => _canWriteTerminalClipboard,
);
if (request != null) _showTerminalClipboardNotice(request);
}
void _showTerminalClipboardNotice(
TerminalClipboardNoticeRequest<int> request,
) {
final controller = ScaffoldMessenger.of(context).showMaterialBanner(
MaterialBanner(
leading: const Icon(Icons.content_copy_outlined),
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
actions: [
AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardNegativeAction
: null,
child: Text(translate(request.negativeActionKey)),
),
),
AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardPositiveAction
: null,
child: Text(translate(request.actionKey)),
),
),
],
),
);
_terminalClipboardNoticeController = controller;
unawaited(controller.closed.then<void>((_) {
if (identical(_terminalClipboardNoticeController, controller)) {
_terminalClipboardNoticeController = null;
_terminalClipboardNotice.noticeClosed();
}
}));
}
void _handleTerminalClipboardNegativeAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
if (request.persistAllowed) {
unawaited(_declineTerminalClipboardWrite());
} else {
_closeTerminalClipboardNotice();
}
}
void _handleTerminalClipboardPositiveAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
unawaited(_completeTerminalClipboardWrite(request));
}
bool get _canWriteTerminalClipboard =>
_canHandleTerminalClipboardWriteRequest &&
!_ffi.closed &&
_ffi.ffiModel.permissions['clipboard'] != false;
void _handleTerminalClipboardWriteSucceeded(String _) {
_closeTerminalClipboardNotice();
}
Future<void> _declineTerminalClipboardWrite() async {
try {
await bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteDenied,
);
} catch (error) {
debugPrint(
'[TerminalPage] Failed to save terminal clipboard permission: $error');
return;
} finally {
_terminalClipboardNotice.releaseAction();
}
_closeTerminalClipboardNotice();
}
Future<void> _completeTerminalClipboardWrite(
TerminalClipboardNoticeRequest<int> request,
) async {
var completed = false;
try {
completed = await completeTerminalClipboardWrite(
clipboardText: request.text,
canWrite: () => _canWriteTerminalClipboard,
writeClipboard: writeTerminalClipboard,
persistAllowed: request.persistAllowed
? () => bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteAllowed,
)
: null,
);
} catch (error) {
debugPrint(
'[TerminalPage] Failed to complete terminal clipboard write: $error');
} finally {
_terminalClipboardNotice.releaseAction();
}
if (!completed) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNotice() {
if (!_terminalClipboardNotice.beginClose()) return;
final controller = _terminalClipboardNoticeController;
if (controller == null) {
debugPrint('[TerminalPage] Clipboard notice controller is missing');
_terminalClipboardNotice.noticeClosed();
return;
}
controller.close();
}
@override @override
void dispose() { void dispose() {
// Unregister terminal model from FFI // Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId); _ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose(); _terminalModel.dispose();
_keyboardDebounce?.cancel(); _keyboardDebounce?.cancel();
_terminalClipboardNotice.clear();
_terminalClipboardNoticeController?.close();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
TerminalConnectionManager.releaseConnection(widget.id); TerminalConnectionManager.releaseConnection(widget.id);
@@ -148,6 +374,40 @@ class _TerminalPageState extends State<TerminalPage>
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight); return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
} }
/// Pastes clipboard text through TerminalModel so keyboard-only modifiers and
/// mobile Enter normalization never alter clipboard data.
Future<void> _pasteClipboardText() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text;
if (text == null || !mounted) return;
await _terminalModel.pasteText(text);
if (mounted) {
_terminalModel.terminalController.clearSelection();
}
}
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
final hardwareKeyboard = HardwareKeyboard.instance;
final shouldPaste = shouldHandleTerminalPasteShortcut(
platform: defaultTargetPlatform,
logicalKey: event.logicalKey,
isKeyDown: event is KeyDownEvent,
isKeyRepeat: event is KeyRepeatEvent,
controlPressed: hardwareKeyboard.isControlPressed,
metaPressed: hardwareKeyboard.isMetaPressed,
altPressed: hardwareKeyboard.isAltPressed,
shiftPressed: hardwareKeyboard.isShiftPressed,
modifierLockActive: _ctrlLocked || _altLocked,
);
if (!shouldPaste) return KeyEventResult.ignored;
// Only locked virtual modifiers need interception. Without a lock, keep
// xterm's default hardware paste behavior, including bracketed paste mode.
unawaited(_pasteClipboardText());
return KeyEventResult.handled;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@@ -172,12 +432,12 @@ class _TerminalPageState extends State<TerminalPage>
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final heightPx = constraints.maxHeight; final heightPx = constraints.maxHeight;
return TerminalView( return _buildTerminalViewForPlatform(
_terminalModel.terminal, reportMouseInput: isWebDesktop || isAndroid,
reportTouchInput: isIOS,
terminal: _terminalModel.terminal,
controller: _terminalModel.terminalController, controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(), textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
// The following comment is from xterm.dart source code: // The following comment is from xterm.dart source code:
// Workaround to detect delete key for platforms and IMEs that do not // Workaround to detect delete key for platforms and IMEs that do not
// emit a hardware delete event. Preferred on mobile platforms. [false] by // emit a hardware delete event. Preferred on mobile platforms. [false] by
@@ -185,6 +445,12 @@ class _TerminalPageState extends State<TerminalPage>
// //
// Android works fine without this workaround. // Android works fine without this workaround.
deleteDetection: isIOS, deleteDetection: isIOS,
shortcuts: platformTerminalShortcuts(),
onKeyEvent: terminalCopyHandler(
_terminalModel.terminal,
_terminalModel.terminalController,
fallback: _handleTerminalKeyEvent,
),
padding: _calculatePadding(heightPx), padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async { onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection; final selection = _terminalModel.terminalController.selection;
@@ -193,11 +459,7 @@ class _TerminalPageState extends State<TerminalPage>
_terminalModel.terminalController.clearSelection(); _terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
} else { } else {
final data = await Clipboard.getData('text/plain'); await _pasteClipboardText();
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
} }
}, },
); );
@@ -324,66 +586,171 @@ class _TerminalPageState extends State<TerminalPage>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Row 1 follows the latest reviewed PR layout.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
),
// Row 2 ends with the full-width Row3 collapse/expand toggle.
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
_buildKeyButton('Esc'), ..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
const SizedBox(width: 2), const SizedBox(width: terminalKeyboardKeySpacing),
_buildKeyButton('/'), _buildCollapseButton(),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
], ],
), ),
// Row 3 restores paging keys and trailing alignment placeholders.
if (_row3Expanded)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
for (var i = 0;
i < terminalKeyboardRow3TrailingPlaceholderCount;
i++) ...[
const SizedBox(width: terminalKeyboardKeySpacing),
const SizedBox(width: terminalKeyboardKeyWidth),
],
],
),
], ],
), ),
), ),
); );
} }
// Ctrl toggle button with highlighted locked state
Widget _buildCtrlKeyButton() {
return _buildModifierToggleButton(
text: 'Ctrl',
semanticsLabel: 'Ctrl',
isLocked: _ctrlLocked,
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
);
}
// Alt toggle button with highlighted locked state
Widget _buildAltKeyButton() {
return _buildModifierToggleButton(
text: 'Alt',
semanticsLabel: 'Alt',
isLocked: _altLocked,
onPressed: () => setState(() => _altLocked = !_altLocked),
);
}
// Collapse/expand toggle button for Row3
void _toggleRow3Expanded() {
final willExpand = !_row3Expanded;
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: _row3Expanded,
willExpand: willExpand,
ctrlLocked: _ctrlLocked,
altLocked: _altLocked,
);
setState(() {
_row3Expanded = willExpand;
if (shouldClearModifiers) {
_ctrlLocked = false;
_altLocked = false;
}
});
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
// The floating keyboard height changes after Row3 is inserted/removed.
// Re-measure on the next frame so terminal padding uses the new height.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showTerminalExtraKeys) return;
setState(() {
_updateKeyboardHeight();
});
});
}
Widget _buildCollapseButton() {
return Semantics(
label: translate('Show terminal extra keys'),
toggled: _row3Expanded,
child: ElevatedButton(
onPressed: _toggleRow3Expanded,
child: Text(_row3Expanded ? '' : ''),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
List<Widget> _buildKeyboardKeyButtons(List<String> labels) {
return [
for (var i = 0; i < labels.length; i++) ...[
_buildKeyButton(labels[i]),
if (i < labels.length - 1)
const SizedBox(width: terminalKeyboardKeySpacing),
],
];
}
/// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior.
/// When [isLocked] is true, the button highlights in blue and the next
/// single-character input is mapped to its modified equivalent.
Widget _buildModifierToggleButton({
required String text,
required String semanticsLabel,
required bool isLocked,
required VoidCallback onPressed,
}) {
return Semantics(
// Ctrl and Alt are technical key names and intentionally stay unchanged.
label: semanticsLabel,
toggled: isLocked,
child: ElevatedButton(
onPressed: onPressed,
child: Text(text),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: isLocked
? Colors.blue
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: isLocked
? Colors.white
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildKeyButton(String label) { Widget _buildKeyButton(String label) {
if (label == 'Ctrl') return _buildCtrlKeyButton();
if (label == 'Alt') return _buildAltKeyButton();
return ElevatedButton( return ElevatedButton(
onPressed: () { onPressed: () {
_sendKeyToTerminal(label); _sendKeyToTerminal(label);
}, },
child: Text(label), child: Text(label),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: const Size(48, 32), minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant, backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
); );
} }
void _sendKeyToTerminal(String key) { void _sendKeyToTerminal(String key) {
String? send; String send;
switch (key) { switch (key) {
case 'Esc': case 'Esc':
@@ -427,9 +794,7 @@ class _TerminalPageState extends State<TerminalPage>
break; break;
} }
if (send != null) { _terminalModel.sendVirtualKey(send);
_terminalModel.sendVirtualKey(send);
}
} }
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472 // https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472

View File

@@ -0,0 +1,20 @@
/// Reviewed mobile terminal keyboard layout from PR #15532.
///
/// Keeping the key order outside the widget makes the intended layout explicit
/// and prevents behavior fixes from silently moving keys between rows.
const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '', 'End', r'\'];
const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '', '', ''];
const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'];
const terminalKeyboardKeyWidth = 48.0;
const terminalKeyboardKeySpacing = 2.0;
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
/// Returns the fixed width occupied by a row of equally sized key slots.
double terminalKeyboardRowWidth(int slotCount) {
if (slotCount <= 0) return 0;
return slotCount * terminalKeyboardKeyWidth +
(slotCount - 1) * terminalKeyboardKeySpacing;
}

View File

@@ -46,6 +46,12 @@ class JobID {
typedef GetSessionID = SessionID Function(); typedef GetSessionID = SessionID Function();
typedef GetDialogManager = OverlayDialogManager? Function(); typedef GetDialogManager = OverlayDialogManager? Function();
typedef ReadRemoteDirectory = Future<void> Function(
SessionID sessionId, String path, bool includeHidden);
const _kRemoteReadDirTimeout = Duration(seconds: 30);
const _kRemoteSessionChangedError =
'Remote directory read cancelled because the session changed';
class FileModel { class FileModel {
final WeakReference<FFI> parent; final WeakReference<FFI> parent;
@@ -84,6 +90,7 @@ class FileModel {
} }
Future<void> onReady() async { Future<void> onReady() async {
fileFetcher.beginRemoteSession();
await evtLoop.onReady(); await evtLoop.onReady();
if (!isWeb) await localController.onReady(); if (!isWeb) await localController.onReady();
await remoteController.onReady(); await remoteController.onReady();
@@ -133,7 +140,11 @@ class FileModel {
final id = int.tryParse(evt['id']?.toString() ?? ''); final id = int.tryParse(evt['id']?.toString() ?? '');
if (id != null) { if (id != null) {
final err = evt['err']?.toString() ?? 'Unknown error'; final err = evt['err']?.toString() ?? 'Unknown error';
fileFetcher.tryCompleteRecursiveTaskWithError(id, err); if (id == 0) {
fileFetcher.tryCompleteRemoteTaskWithError(err);
} else {
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
}
} }
// Always call jobController.jobError(evt) to ensure all error events are processed, // Always call jobController.jobError(evt) to ensure all error events are processed,
// even if the event does not have a valid job ID. This allows for generic error handling // even if the event does not have a valid job ID. This allows for generic error handling
@@ -350,6 +361,8 @@ class FileController {
final history = RxList<String>.empty(growable: true); final history = RxList<String>.empty(growable: true);
final sortBy = SortBy.name.obs; final sortBy = SortBy.name.obs;
var sortAscending = true; var sortAscending = true;
// Incremented for each navigation; only the latest generation applies results.
int _directoryRequestGeneration = 0;
final JobController jobController; final JobController jobController;
final WeakReference<FFI> rootState; final WeakReference<FFI> rootState;
@@ -368,6 +381,14 @@ class FileController {
void set homePath(String path) => options.value.home = path; void set homePath(String path) => options.value.home = path;
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager; OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
bool _isPathAllowed(String candidate) {
if (!isAndroid || !isLocal) return true;
if (homePath.isEmpty || candidate.isEmpty) return false;
final home = PathUtil.posixContext.normalize(homePath);
final target = PathUtil.posixContext.normalize(candidate);
return target == home || PathUtil.posixContext.isWithin(home, target);
}
String get shortPath { String get shortPath {
final dirPath = directory.value.path; final dirPath = directory.value.path;
if (dirPath.startsWith(homePath)) { if (dirPath.startsWith(homePath)) {
@@ -401,8 +422,13 @@ class FileController {
await Future.delayed(Duration(milliseconds: 100)); await Future.delayed(Duration(milliseconds: 100));
final savedDir = (await bind.sessionGetPeerOption( var savedDir = (await bind.sessionGetPeerOption(
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir")); sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
savedDir = options.value.home;
await bind.sessionPeerOption(
sessionId: sessionId, name: "local_dir", value: savedDir);
}
Future<bool> tryOpenReadyDirs() async { Future<bool> tryOpenReadyDirs() async {
final dirs = <String>{ final dirs = <String>{
if (directory.value.path.isNotEmpty) directory.value.path, if (directory.value.path.isNotEmpty) directory.value.path,
@@ -472,6 +498,9 @@ class FileController {
} }
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async { Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
if (!_isPathAllowed(path)) {
return false;
}
if (!isBack) { if (!isBack) {
pushHistory(); pushHistory();
} }
@@ -484,12 +513,20 @@ class FileController {
path = "$path\\"; path = "$path\\";
} }
} }
final requestGeneration = ++_directoryRequestGeneration;
try { try {
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden); final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
if (requestGeneration != _directoryRequestGeneration) {
return true;
}
fd.format(isWindows, sort: sortBy.value); fd.format(isWindows, sort: sortBy.value);
selectedItems.reconcile(fd.entries);
directory.value = fd; directory.value = fd;
return true; return true;
} catch (e) { } catch (e) {
if (requestGeneration != _directoryRequestGeneration) {
return true;
}
debugPrint("Failed to openDirectory $path: $e"); debugPrint("Failed to openDirectory $path: $e");
return false; return false;
} }
@@ -530,6 +567,9 @@ class FileController {
final isWindows = options.value.isWindows; final isWindows = options.value.isWindows;
final dirPath = directory.value.path; final dirPath = directory.value.path;
var parent = PathUtil.dirname(dirPath, isWindows); var parent = PathUtil.dirname(dirPath, isWindows);
if (!_isPathAllowed(parent)) {
return true;
}
// specially for C:\, D:\, goto '/' // specially for C:\, D:\, goto '/'
if (parent == dirPath && isWindows) { if (parent == dirPath && isWindows) {
return await _openDirectoryPath('/', isBack: isBack); return await _openDirectoryPath('/', isBack: isBack);
@@ -541,6 +581,7 @@ class FileController {
void initDirAndHome(Map<String, dynamic> evt) { void initDirAndHome(Map<String, dynamic> evt) {
try { try {
final fd = FileDirectory.fromJson(jsonDecode(evt['value'])); final fd = FileDirectory.fromJson(jsonDecode(evt['value']));
final isHomeResponse = fileFetcher.isLikelyRemoteHomeResponse(fd.path);
fd.format(options.value.isWindows, sort: sortBy.value); fd.format(options.value.isWindows, sort: sortBy.value);
if (fd.id > 0) { if (fd.id > 0) {
final jobIndex = jobController.getJob(fd.id); final jobIndex = jobController.getJob(fd.id);
@@ -556,10 +597,12 @@ class FileController {
debugPrint("update receive details: ${fd.path}"); debugPrint("update receive details: ${fd.path}");
jobController.jobTable.refresh(); jobController.jobTable.refresh();
} }
} else if (options.value.home.isEmpty) { } else if (options.value.home.isEmpty && isHomeResponse) {
options.value.home = fd.path; options.value.home = fd.path;
debugPrint("init remote home: ${fd.path}"); debugPrint("init remote home: ${fd.path}");
directory.value = fd; if (_directoryRequestGeneration == 0) {
directory.value = fd;
}
} }
} catch (e) { } catch (e) {
debugPrint("initDirAndHome err=$e"); debugPrint("initDirAndHome err=$e");
@@ -1362,16 +1405,78 @@ class JobResultListener<T> {
} }
} }
class _RemoteReadTask {
final bool includeHidden;
final Completer<FileDirectory> completer = Completer<FileDirectory>();
final Completer<void> released = Completer<void>();
late final Timer timer;
_RemoteReadTask(this.includeHidden);
}
class FileFetcher { class FileFetcher {
// Map<String,Completer<FileDirectory>> localTasks = {}; // now we only use read local dir sync // Map<String,Completer<FileDirectory>> localTasks = {}; // now we only use read local dir sync
Map<String, Completer<FileDirectory>> remoteTasks = {}; final Map<String, _RemoteReadTask> _remoteReadTasks = {};
Map<String, Completer<List<FileDirectory>>> remoteEmptyDirsTasks = {}; Map<String, Completer<List<FileDirectory>>> remoteEmptyDirsTasks = {};
Map<int, Completer<FileDirectory>> readRecursiveTasks = {}; Map<int, Completer<FileDirectory>> readRecursiveTasks = {};
int _remoteSessionGeneration = 0;
final GetSessionID getSessionID; final GetSessionID getSessionID;
final ReadRemoteDirectory _readRemoteDirectory;
SessionID get sessionId => getSessionID(); SessionID get sessionId => getSessionID();
FileFetcher(this.getSessionID); FileFetcher(this.getSessionID, {ReadRemoteDirectory? readRemoteDirectory})
: _readRemoteDirectory = readRemoteDirectory ??
((sessionId, path, includeHidden) => bind.sessionReadRemoteDir(
sessionId: sessionId,
path: path,
includeHidden: includeHidden));
bool hasPendingRemoteRead(String path) => _remoteReadTasks.containsKey(path);
bool isLikelyRemoteHomeResponse(String path) =>
_remoteReadTasks.isEmpty ||
(_remoteReadTasks.length == 1 &&
hasPendingRemoteRead("") &&
!hasPendingRemoteRead(path));
void beginRemoteSession() {
_remoteSessionGeneration++;
final pendingTasks = _remoteReadTasks.entries.toList(growable: false);
for (final entry in pendingTasks) {
final task = entry.value;
if (!_removeRemoteReadTask(entry.key, task)) continue;
task.completer.completeError(StateError(_kRemoteSessionChangedError));
}
}
_RemoteReadTask _registerRemoteReadTask(String path, bool includeHidden) {
if (hasPendingRemoteRead(path)) {
throw "Failed to registerReadTask, already have same read job";
}
final task = _RemoteReadTask(includeHidden);
_remoteReadTasks[path] = task;
task.timer = Timer(_kRemoteReadDirTimeout, () {
if (!_removeRemoteReadTask(path, task)) return;
task.completer.completeError("Failed to read dir, timeout");
});
return task;
}
bool _removeRemoteReadTask(String path, _RemoteReadTask task) {
if (!identical(_remoteReadTasks[path], task)) return false;
_remoteReadTasks.remove(path);
task.timer.cancel();
task.released.complete();
return true;
}
bool _completeRemoteReadTask(String path, FileDirectory directory) {
final task = _remoteReadTasks[path];
if (task == null || !_removeRemoteReadTask(path, task)) return false;
task.completer.complete(directory);
return true;
}
Future<List<FileDirectory>> registerReadEmptyDirsTask( Future<List<FileDirectory>> registerReadEmptyDirsTask(
bool isLocal, String path) { bool isLocal, String path) {
@@ -1391,23 +1496,6 @@ class FileFetcher {
return c.future; return c.future;
} }
Future<FileDirectory> registerReadTask(bool isLocal, String path) {
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
final tasks = remoteTasks; // bypass now
if (tasks.containsKey(path)) {
throw "Failed to registerReadTask, already have same read job";
}
final c = Completer<FileDirectory>();
tasks[path] = c;
Timer(Duration(seconds: 2), () {
tasks.remove(path);
if (c.isCompleted) return;
c.completeError("Failed to read dir, timeout");
});
return c.future;
}
Future<FileDirectory> registerReadRecursiveTask(int actID) { Future<FileDirectory> registerReadRecursiveTask(int actID) {
final tasks = readRecursiveTasks; final tasks = readRecursiveTasks;
if (tasks.containsKey(actID)) { if (tasks.containsKey(actID)) {
@@ -1445,27 +1533,37 @@ class FileFetcher {
tryCompleteTask(String? msg, String? isLocalStr) { tryCompleteTask(String? msg, String? isLocalStr) {
if (msg == null || isLocalStr == null) return; if (msg == null || isLocalStr == null) return;
late final Map<Object, Completer<FileDirectory>> tasks;
try { try {
final fd = FileDirectory.fromJson(jsonDecode(msg)); final fd = FileDirectory.fromJson(jsonDecode(msg));
if (fd.id > 0) { if (fd.id > 0) {
// fd.id > 0 is result for read recursive // fd.id > 0 is result for read recursive
// to-do later,will be better if every fetch use ID,so that there will only one task map for read and recursive read final completer = readRecursiveTasks.remove(fd.id);
tasks = readRecursiveTasks;
final completer = tasks.remove(fd.id);
completer?.complete(fd);
} else if (fd.path.isNotEmpty) {
// result for normal read dir
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
tasks = remoteTasks; // bypass now
final completer = tasks.remove(fd.path);
completer?.complete(fd); completer?.complete(fd);
return;
}
if (isLocalStr == "false" && fd.path.isNotEmpty) {
if (_completeRemoteReadTask(fd.path, fd)) {
return;
}
// A Home request uses an empty path but returns its resolved path.
if (isLikelyRemoteHomeResponse(fd.path)) {
_completeRemoteReadTask("", fd);
}
} }
} catch (e) { } catch (e) {
debugPrint("tryCompleteJob err: $e"); debugPrint("tryCompleteJob err: $e");
} }
} }
bool tryCompleteRemoteTaskWithError(String error) {
if (_remoteReadTasks.length != 1) return false;
final entry = _remoteReadTasks.entries.single;
final task = entry.value;
if (!_removeRemoteReadTask(entry.key, task)) return false;
task.completer.completeError(error);
return true;
}
// Complete a pending recursive read task with an error. // Complete a pending recursive read task with an error.
// See FileModel.handleJobError() for why this is necessary. // See FileModel.handleJobError() for why this is necessary.
void tryCompleteRecursiveTaskWithError(int id, String error) { void tryCompleteRecursiveTaskWithError(int id, String error) {
@@ -1506,9 +1604,26 @@ class FileFetcher {
final fd = FileDirectory.fromJson(jsonDecode(res)); final fd = FileDirectory.fromJson(jsonDecode(res));
return fd; return fd;
} else { } else {
await bind.sessionReadRemoteDir( final remoteSessionGeneration = _remoteSessionGeneration;
sessionId: sessionId, path: path, includeHidden: showHidden); final pendingTask = _remoteReadTasks[path];
return registerReadTask(isLocal, path); if (pendingTask != null) {
if (pendingTask.includeHidden == showHidden) {
return pendingTask.completer.future;
}
await pendingTask.released.future;
if (remoteSessionGeneration != _remoteSessionGeneration) {
throw StateError(_kRemoteSessionChangedError);
}
return fetchDirectory(path, isLocal, showHidden);
}
final task = _registerRemoteReadTask(path, showHidden);
unawaited(Future<void>.sync(
() => _readRemoteDirectory(sessionId, path, showHidden))
.catchError((Object error, StackTrace stackTrace) {
if (!_removeRemoteReadTask(path, task)) return;
task.completer.completeError(error, stackTrace);
}));
return task.completer.future;
} }
} catch (e) { } catch (e) {
return Future.error(e); return Future.error(e);
@@ -1790,7 +1905,7 @@ class PathUtil {
} }
static bool validName(String name, bool isWindows) { static bool validName(String name, bool isWindows) {
final unixFileNamePattern = RegExp(r'^[^/\0]+$'); final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$'); final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern; final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
return reg.hasMatch(name); return reg.hasMatch(name);
@@ -1833,6 +1948,21 @@ class SelectedItems {
items.clear(); items.clear();
} }
void reconcile(List<Entry> entries) {
if (items.isEmpty) return;
final currentByPath = {for (final entry in entries) entry.path: entry};
final reconciled = <Entry>[];
for (final item in items) {
final current = currentByPath[item.path];
if (current != null && current.entryType == item.entryType) {
reconciled.add(current);
}
}
items
..clear()
..addAll(reconciled);
}
void selectAll(List<Entry> entries) { void selectAll(List<Entry> entries) {
items.clear(); items.clear();
items.addAll(entries); items.addAll(entries);

View File

@@ -1787,6 +1787,11 @@ class InputModel {
} }
bool _checkPeerControlProtected(double x, double y) { bool _checkPeerControlProtected(double x, double y) {
if (isViewOnly && showMyCursor) {
lastMousePos = ui.Offset(x, y);
return false;
}
final cursorModel = parent.target!.cursorModel; final cursorModel = parent.target!.cursorModel;
if (cursorModel.isPeerControlProtected) { if (cursorModel.isPeerControlProtected) {
lastMousePos = ui.Offset(x, y); lastMousePos = ui.Offset(x, y);

View File

@@ -1,4 +1,12 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Identifies where terminal input originated so paste data can bypass all
/// keyboard-only transformations.
enum TerminalInputSource {
keyboard,
paste,
}
/// Returns true when a stale mobile one-shot Shift state should be released /// Returns true when a stale mobile one-shot Shift state should be released
/// by replaying a tracked Shift key-down as a synthesized key-up. /// by replaying a tracked Shift key-down as a synthesized key-up.
@@ -36,3 +44,158 @@ bool shouldReleaseStaleMobileShift({
} }
return true; return true;
} }
/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload.
///
String applyTerminalInputModifiers(
String data, {
required bool ctrlLocked,
required bool altLocked,
}) {
var result = data;
if (ctrlLocked) {
result = _applyTerminalCtrlModifier(result);
}
if (altLocked) {
result = '\x1B$result';
}
return result;
}
/// Builds the exact payload xterm sends for paste, without applying modifiers.
String terminalPastePayload(String text, {required bool bracketedPasteMode}) {
if (!bracketedPasteMode) {
return text;
}
return '\x1B[200~$text\x1B[201~';
}
/// Returns whether one-shot Ctrl/Alt may transform and consume this input.
///
/// xterm emits terminal control keys as either one control byte or a longer
/// escape sequence. Neither form is ordinary text input, so a pending modifier
/// must survive until the user enters a printable character.
bool shouldApplyTerminalInputModifiers(String data) {
if (data.characters.length != 1) return false;
final codeUnit = data.codeUnitAt(0);
return codeUnit >= 0x20 && codeUnit != 0x7F;
}
/// Builds the payload sent to the remote terminal for keyboard and paste input.
///
/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt
/// mapping. Paste input deliberately bypasses both transformations so even a
/// one-character clipboard payload is preserved exactly.
String prepareTerminalInputPayload(
String data, {
required TerminalInputSource source,
required bool isMobileOrWebMobile,
required bool bracketedPasteMode,
required bool ctrlLocked,
required bool altLocked,
}) {
if (source == TerminalInputSource.paste) {
return terminalPastePayload(
data,
bracketedPasteMode: bracketedPasteMode,
);
}
var result = data;
if (isMobileOrWebMobile && result == '\n') {
result = '\r';
}
if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) {
result = applyTerminalInputModifiers(
result,
ctrlLocked: ctrlLocked,
altLocked: altLocked,
);
}
return result;
}
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
///
/// xterm already handles each platform's paste shortcut in the common case.
/// Only intercept while a virtual Ctrl/Alt lock is active, because xterm can
/// emit a one-character paste as normal text when bracketed paste mode is off.
bool shouldHandleTerminalPasteShortcut({
required TargetPlatform platform,
required LogicalKeyboardKey logicalKey,
required bool isKeyDown,
required bool isKeyRepeat,
required bool controlPressed,
required bool metaPressed,
required bool altPressed,
required bool shiftPressed,
required bool modifierLockActive,
}) {
if (!modifierLockActive) return false;
if (!isKeyDown && !isKeyRepeat) return false;
if (logicalKey != LogicalKeyboardKey.keyV) return false;
if (altPressed) return false;
switch (platform) {
case TargetPlatform.linux:
return controlPressed && !metaPressed && shiftPressed;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return !controlPressed && metaPressed && !shiftPressed;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.windows:
return controlPressed && !metaPressed && !shiftPressed;
}
}
/// Returns true when collapsing Row3 should also clear hidden modifier state.
bool shouldClearTerminalModifiersWhenRow3Collapses({
required bool wasExpanded,
required bool willExpand,
required bool ctrlLocked,
required bool altLocked,
}) {
return wasExpanded && !willExpand && (ctrlLocked || altLocked);
}
String _applyTerminalCtrlModifier(String data) {
// Ctrl mappings are defined only for ASCII scalars. A visible character can
// be multiple scalars (for example, a decomposed accent), so leave those
// graphemes untouched instead of rewriting only their ASCII base letter.
final graphemes = data.characters.toList(growable: false);
if (graphemes.length != 1) {
return data;
}
final runes = graphemes.single.runes.toList(growable: false);
if (runes.length != 1) {
return data;
}
final code = runes.single;
if (code >= 0x61 && code <= 0x7A) {
return String.fromCharCode(code - 0x60);
}
if (code >= 0x41 && code <= 0x5A) {
return String.fromCharCode(code - 0x40);
}
if (code == 0x20) {
return String.fromCharCode(0);
}
if (code == 0x5B) {
return String.fromCharCode(27);
}
if (code == 0x5C) {
return String.fromCharCode(28);
}
if (code == 0x5D) {
return String.fromCharCode(29);
}
if (code == 0x5E) {
return String.fromCharCode(30);
}
if (code == 0x5F || code == 0x2F) {
return String.fromCharCode(31);
}
return data;
}

View File

@@ -25,9 +25,6 @@ import 'package:flutter_hbb/models/user_model.dart';
import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/models/desktop_render_texture.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart';
import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/plugin/event.dart';
import 'package:flutter_hbb/plugin/manager.dart';
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/utils/http_service.dart' as http; import 'package:flutter_hbb/utils/http_service.dart' as http;
@@ -127,6 +124,8 @@ class FfiModel with ChangeNotifier {
Timer? _restartReconnectDelayTimer; Timer? _restartReconnectDelayTimer;
var _reconnects = 1; var _reconnects = 1;
DateTime? _offlineReconnectStartTime; DateTime? _offlineReconnectStartTime;
bool _androidDocumentPickerActive = false;
bool _androidDocumentPickerInterruptedConnection = false;
bool _viewOnly = false; bool _viewOnly = false;
bool _showMyCursor = false; bool _showMyCursor = false;
WeakReference<FFI> parent; WeakReference<FFI> parent;
@@ -258,6 +257,8 @@ class FfiModel with ChangeNotifier {
_inputBlocked = false; _inputBlocked = false;
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
_androidDocumentPickerActive = false;
_androidDocumentPickerInterruptedConnection = false;
resetRestartReconnectState(); resetRestartReconnectState();
clearPermissions(); clearPermissions();
waitForImageTimer?.cancel(); waitForImageTimer?.cancel();
@@ -437,15 +438,6 @@ class FfiModel with ChangeNotifier {
parent.target?.serverModel.updateVoiceCallState(evt); parent.target?.serverModel.updateVoiceCallState(evt);
} else if (name == 'fingerprint') { } else if (name == 'fingerprint') {
FingerprintState.find(peerId).value = evt['fingerprint'] ?? ''; FingerprintState.find(peerId).value = evt['fingerprint'] ?? '';
} else if (name == 'plugin_manager') {
pluginManager.handleEvent(evt);
} else if (name == 'plugin_event') {
handlePluginEvent(evt,
(Map<String, dynamic> e) => handleMsgBox(e, sessionId, peerId));
} else if (name == 'plugin_reload') {
handleReloading(evt);
} else if (name == 'plugin_option') {
handleOption(evt);
} else if (name == "sync_peer_hash_password_to_personal_ab") { } else if (name == "sync_peer_hash_password_to_personal_ab") {
if (desktopType == DesktopType.main || isWeb || isMobile) { if (desktopType == DesktopType.main || isWeb || isMobile) {
final id = evt['id']; final id = evt['id'];
@@ -904,6 +896,17 @@ class FfiModel with ChangeNotifier {
final text = evt['text']; final text = evt['text'];
final link = evt['link']; final link = evt['link'];
// The peer-gone detector reconnects under `restarting-show` rather than an error title, so
// it needs naming here too. By its own title, not the type: an explicitly restarted remote
// device reaches the same type from a path this change does not touch.
if (isAndroid &&
_androidDocumentPickerActive &&
(title == 'Connection Error' ||
(type == 'restarting-show' && title == 'Connecting...'))) {
_androidDocumentPickerInterruptedConnection = true;
return;
}
// Disable relative mouse mode on any error-type message to ensure cursor is released. // Disable relative mouse mode on any error-type message to ensure cursor is released.
// This includes connection errors, session-ending messages, elevation errors, etc. // This includes connection errors, session-ending messages, elevation errors, etc.
// Safety: releasing pointer lock on errors prevents the user from being stuck. // Safety: releasing pointer lock on errors prevents the user from being stuck.
@@ -920,17 +923,12 @@ class FfiModel with ChangeNotifier {
enter2FaDialog(sessionId, dialogManager); enter2FaDialog(sessionId, dialogManager);
} else if (type == 'input-password') { } else if (type == 'input-password') {
enterPasswordDialog(sessionId, dialogManager); enterPasswordDialog(sessionId, dialogManager);
} else if (type == 'session-login' || type == 'session-re-login') {
enterUserLoginDialog(sessionId, dialogManager, 'login_linux_tip', true);
} else if (type == 'session-login-password') {
enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'login_linux_tip', true);
} else if (type == 'terminal-admin-login') { } else if (type == 'terminal-admin-login') {
enterUserLoginDialog( enterUserLoginDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false); sessionId, dialogManager, 'terminal-admin-login-tip');
} else if (type == 'terminal-admin-login-password') { } else if (type == 'terminal-admin-login-password') {
enterUserLoginAndPasswordDialog( enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false); sessionId, dialogManager, 'terminal-admin-login-tip');
} else if (type == 'restarting') { } else if (type == 'restarting') {
// Treat restart messages as reconnect control events. Rust still sends // Treat restart messages as reconnect control events. Rust still sends
// title/text for legacy UI and translation reuse; Flutter keeps the last // title/text for legacy UI and translation reuse; Flutter keeps the last
@@ -985,6 +983,23 @@ class FfiModel with ChangeNotifier {
_restartReconnectDelayTimer = null; _restartReconnectDelayTimer = null;
} }
void beginAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = true;
_androidDocumentPickerInterruptedConnection = false;
}
void endAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = false;
if (!_androidDocumentPickerInterruptedConnection ||
parent.target?.closed == true) {
return;
}
_androidDocumentPickerInterruptedConnection = false;
reconnect(parent.target!.dialogManager, sessionId, false);
}
/// Auto-retry check for "Remote desktop is offline" error. /// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise. /// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline( bool shouldAutoRetryOnOffline(
@@ -1952,6 +1967,12 @@ class ImageModel with ChangeNotifier {
platformFFI.nextRgba(sessionId, display); platformFFI.nextRgba(sessionId, display);
} }
// web only: image already created from a decoded WebCodecs frame
Future<void> onImage(
int display, ui.Image image, bool Function() isCurrentSession) async {
await update(image, isCurrentSession: isCurrentSession);
}
decodeAndUpdate(int display, Uint8List rgba) async { decodeAndUpdate(int display, Uint8List rgba) async {
final pid = parent.target?.id; final pid = parent.target?.id;
final rect = parent.target?.ffiModel.pi.getDisplayRect(display); final rect = parent.target?.ffiModel.pi.getDisplayRect(display);
@@ -1963,11 +1984,16 @@ class ImageModel with ChangeNotifier {
? ui.PixelFormat.rgba8888 ? ui.PixelFormat.rgba8888
: ui.PixelFormat.bgra8888, : ui.PixelFormat.bgra8888,
); );
if (parent.target?.id != pid) return; if (parent.target?.id != pid) {
image?.dispose();
return;
}
await update(image); await update(image);
} }
update(ui.Image? image) async { Future<void> update(ui.Image? image,
{bool Function()? isCurrentSession}) async {
if (_disposeIfStale(image, isCurrentSession)) return;
if (_image == null && image != null) { if (_image == null && image != null) {
if (isDesktop || isWebDesktop) { if (isDesktop || isWebDesktop) {
await parent.target?.canvasModel.updateViewStyle(); await parent.target?.canvasModel.updateViewStyle();
@@ -1978,11 +2004,19 @@ class ImageModel with ChangeNotifier {
await initializeCursorAndCanvas(parent.target!); await initializeCursorAndCanvas(parent.target!);
} }
} }
if (_disposeIfStale(image, isCurrentSession)) return;
_image?.dispose(); _image?.dispose();
_image = image; _image = image;
if (image != null) notifyListeners(); if (image != null) notifyListeners();
} }
bool _disposeIfStale(ui.Image? image, bool Function()? isCurrentSession) {
if (image == null || isCurrentSession == null) return false;
if (isCurrentSession()) return false;
image.dispose();
return true;
}
// mobile only // mobile only
double get maxScale { double get maxScale {
if (_image == null) return 1.5; if (_image == null) return 1.5;
@@ -2206,6 +2240,7 @@ class CanvasModel with ChangeNotifier {
double _y = 0; double _y = 0;
// image scale // image scale
double _scale = 1.0; double _scale = 1.0;
bool _locked = false;
double _devicePixelRatio = 1.0; double _devicePixelRatio = 1.0;
Size _size = Size.zero; Size _size = Size.zero;
// the tabbar over the image // the tabbar over the image
@@ -2254,12 +2289,19 @@ class CanvasModel with ChangeNotifier {
double get x => _x; double get x => _x;
double get y => _y; double get y => _y;
double get scale => _scale; double get scale => _scale;
bool get locked => _locked;
double get devicePixelRatio => _devicePixelRatio; double get devicePixelRatio => _devicePixelRatio;
Size get size => _size; Size get size => _size;
ScrollStyle get scrollStyle => _scrollStyle; ScrollStyle get scrollStyle => _scrollStyle;
ViewStyle get viewStyle => _lastViewStyle; ViewStyle get viewStyle => _lastViewStyle;
RxBool get imageOverflow => _imageOverflow; RxBool get imageOverflow => _imageOverflow;
void setLocked(bool value) {
if (_locked == value) return;
_locked = value;
notifyListeners();
}
_resetScroll() => setScrollPercent(0.0, 0.0); _resetScroll() => setScrollPercent(0.0, 0.0);
void setScrollPercent(double x, double y) { void setScrollPercent(double x, double y) {
@@ -2488,6 +2530,7 @@ class CanvasModel with ChangeNotifier {
} }
void updateLocalCursor(double x, double y) { void updateLocalCursor(double x, double y) {
if (parent.target?.ffiModel.viewOnly == true) return;
// If keyboard is not permitted, do not move cursor when mouse is moving. // If keyboard is not permitted, do not move cursor when mouse is moving.
if (parent.target != null && parent.target!.ffiModel.keyboard) { if (parent.target != null && parent.target!.ffiModel.keyboard) {
// Draw cursor if is not desktop. // Draw cursor if is not desktop.
@@ -2720,6 +2763,7 @@ class CanvasModel with ChangeNotifier {
_x = 0; _x = 0;
_y = 0; _y = 0;
_scale = 1.0; _scale = 1.0;
_locked = false;
_lastViewStyle = ViewStyle.defaultViewStyle(); _lastViewStyle = ViewStyle.defaultViewStyle();
_timerMobileFocusCanvasCursor?.cancel(); _timerMobileFocusCanvasCursor?.cancel();
_timerMobileRestoreCanvasOffset?.cancel(); _timerMobileRestoreCanvasOffset?.cancel();
@@ -2831,7 +2875,7 @@ class CursorData {
required this.width, required this.width,
required this.height, required this.height,
}) : hotx = hotxOrigin * scale, }) : hotx = hotxOrigin * scale,
hoty = hotxOrigin * scale; hoty = hotyOrigin * scale;
int _doubleToInt(double v) => (v * 10e6).round().toInt(); int _doubleToInt(double v) => (v * 10e6).round().toInt();
@@ -3557,6 +3601,16 @@ class QualityMonitorModel with ChangeNotifier {
bool get show => _show; bool get show => _show;
QualityMonitorData get data => _data; 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 { checkShowQualityMonitor(SessionID sessionId) async {
final show = await bind.sessionGetToggleOption( final show = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: 'show-quality-monitor') == sessionId: sessionId, arg: 'show-quality-monitor') ==
@@ -3853,6 +3907,15 @@ class FFI {
onEvent2UIRgba(); onEvent2UIRgba();
imageModel.onRgba(display, data); imageModel.onRgba(display, data);
}); });
platformFFI.setVideoFrameCallback((int display, ui.Image image,
bool Function() isCurrentSession) async {
if (!isCurrentSession()) {
image.dispose();
return;
}
await onEvent2UIRgba();
await imageModel.onImage(display, image, isCurrentSession);
});
this.id = id; this.id = id;
return; return;
} }
@@ -3940,7 +4003,7 @@ class FFI {
this.id = id; this.id = id;
} }
void onEvent2UIRgba() async { Future<void> onEvent2UIRgba() async {
if (ffiModel.waitForImageDialogShow.isTrue) { if (ffiModel.waitForImageDialogShow.isTrue) {
ffiModel.waitForImageDialogShow.value = false; ffiModel.waitForImageDialogShow.value = false;
ffiModel.waitForImageTimer?.cancel(); ffiModel.waitForImageTimer?.cancel();
@@ -3996,6 +4059,9 @@ class FFI {
/// Close the remote session. /// Close the remote session.
Future<void> close({bool closeSession = true}) async { Future<void> close({bool closeSession = true}) async {
closed = true; closed = true;
if (isWeb) {
platformFFI.clearVideoFrameCallback();
}
chatModel.close(); chatModel.close();
// Close all terminal models // Close all terminal models
for (final model in _terminalModels.values) { for (final model in _terminalModels.values) {
@@ -4036,6 +4102,11 @@ class FFI {
return await platformFFI.invokeMethod(method, arguments); return await platformFFI.invokeMethod(method, arguments);
} }
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
}
// Terminal model management // Terminal model management
void registerTerminalModel(int terminalId, TerminalModel model) { void registerTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Registering terminal model for terminal $terminalId'); debugPrint('[FFI] Registering terminal model for terminal $terminalId');
@@ -4140,7 +4211,6 @@ class PeerInfo with ChangeNotifier {
RxBool isSet = false.obs; RxBool isSet = false.obs;
bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true; bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true;
bool get isHeadless => platformAdditions[kPlatformAdditionsHeadless] == true;
bool get isInstalled => bool get isInstalled =>
platform != kPeerPlatformWindows || platform != kPeerPlatformWindows ||
platformAdditions[kPlatformAdditionsIsInstalled] == true; platformAdditions[kPlatformAdditionsIsInstalled] == true;

View File

@@ -1,9 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:ffi'; import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:external_path/external_path.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -25,6 +25,23 @@ typedef F3 = Pointer<Uint8> Function(Pointer<Utf8>, int);
typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32); typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32);
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt); typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
/// The Linux bundle keeps the core library at lib/librustdesk.so next to the
/// executable. Prefer that copy, mirroring flutter/linux/main.cc: the plain
/// name relies on the loader search path, which repackaged installs may not
/// cover. https://github.com/rustdesk/rustdesk/discussions/14407
DynamicLibrary _openLinuxCoreLib() {
final bundled =
'${File(Platform.resolvedExecutable).parent.path}/lib/librustdesk.so';
try {
if (File(bundled).existsSync()) {
return DynamicLibrary.open(bundled);
}
} catch (e) {
debugPrint("Failed to load '$bundled': $e");
}
return DynamicLibrary.open('librustdesk.so');
}
/// FFI wrapper around the native Rust core. /// FFI wrapper around the native Rust core.
/// Hides the platform differences. /// Hides the platform differences.
class PlatformFFI { class PlatformFFI {
@@ -120,7 +137,7 @@ class PlatformFFI {
final dylib = isAndroid final dylib = isAndroid
? DynamicLibrary.open('librustdesk.so') ? DynamicLibrary.open('librustdesk.so')
: isLinux : isLinux
? DynamicLibrary.open('librustdesk.so') ? _openLinuxCoreLib()
: isWindows : isWindows
? DynamicLibrary.open('librustdesk.dll') ? DynamicLibrary.open('librustdesk.dll')
: :
@@ -153,8 +170,10 @@ class PlatformFFI {
_startListenEvent(_ffiBind); // global event _startListenEvent(_ffiBind); // global event
try { try {
if (isAndroid) { if (isAndroid) {
// only support for android // Android file transfer uses app-specific storage. User-selected
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0]; // files enter and leave this workspace through the system picker.
_homeDir = (await getExternalStorageDirectory())?.path ??
(await getApplicationSupportDirectory()).path;
} else if (isIOS) { } else if (isIOS) {
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`, // The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
// which provided the `downloads` path in the sandbox. // which provided the `downloads` path in the sandbox.
@@ -266,6 +285,12 @@ class PlatformFFI {
void setRgbaCallback(void Function(int, Uint8List) fun) async {} void setRgbaCallback(void Function(int, Uint8List) fun) async {}
// web only, decoded WebCodecs frames arriving as ready-made images
void setVideoFrameCallback(
Future<void> Function(int, ui.Image, bool Function()) fun) {}
void clearVideoFrameCallback() {}
void startDesktopWebListener() {} void startDesktopWebListener() {}
void stopDesktopWebListener() {} void stopDesktopWebListener() {}
@@ -282,6 +307,12 @@ class PlatformFFI {
return await _toAndroidChannel.invokeMethod(method, arguments); return await _toAndroidChannel.invokeMethod(method, arguments);
} }
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
if (!isAndroid) return null;
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
}
void syncAndroidServiceAppDirConfigPath() { void syncAndroidServiceAppDirConfigPath() {
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir); invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
} }

View File

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

View File

@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
_audioOk = audioOption != 'N'; _audioOk = audioOption != 'N';
} }
// file // Android file transfer is confined to app-specific storage. Files enter
if (!await AndroidPermissionManager.check(kManageExternalStorage)) { // and leave the workspace through Android's system document picker.
_fileOk = false; final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N"); _fileOk = fileOption != 'N';
} else {
final fileOption =
await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption != 'N';
}
// clipboard // clipboard
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard); final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
if (clients.any((c) => !c.disconnected)) { if (clients.any((c) => !c.disconnected)) {
await showClientsMayNotBeChangedAlert(parent.target); await showClientsMayNotBeChangedAlert(parent.target);
} }
if (!_fileOk &&
!await AndroidPermissionManager.check(kManageExternalStorage)) {
final res =
await AndroidPermissionManager.request(kManageExternalStorage);
if (!res) {
showToast(translate('Failed'));
return;
}
}
_fileOk = !_fileOk; _fileOk = !_fileOk;
bind.mainSetOption( bind.mainSetOption(
key: kOptionEnableFileTransfer, key: kOptionEnableFileTransfer,
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') { if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
await checkFloatingWindowPermission(); await checkFloatingWindowPermission();
} }
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
await AndroidPermissionManager.request(kManageExternalStorage);
}
final res = await parent.target?.dialogManager final res = await parent.target?.dialogManager
.show<bool>((setState, close, context) { .show<bool>((setState, close, context) {
submit() => close(true); submit() => close(true);
@@ -738,9 +720,13 @@ class ServerModel with ChangeNotifier {
} }
} }
Future<void> closeAll() async { /// `byOperator` false means the CM's window went away rather than a person asking for the
await Future.wait( /// peers to go. The sessions end either way; only the close reason differs, and with it
_clients.map((client) => bind.cmCloseConnection(connId: client.id))); /// whether the peer is allowed to reconnect. See `ipc::Data::CmWindowClosed`.
Future<void> closeAll({bool byOperator = true}) async {
await Future.wait(_clients.map((client) => byOperator
? bind.cmCloseConnection(connId: client.id)
: bind.cmCloseConnectionWindow(connId: client.id)));
_clients.clear(); _clients.clear();
tabController.state.value.tabs.clear(); tabController.state.value.tabs.clear();
if (isAndroid) androidUpdatekeepScreenOn(); if (isAndroid) androidUpdatekeepScreenOn();

View File

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

View File

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

View File

@@ -0,0 +1,201 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:xterm/xterm.dart';
import 'terminal_clipboard_writer.dart'
if (dart.library.html) 'terminal_clipboard_writer_web.dart';
const _controlShiftVPasteShortcut = SingleActivator(
LogicalKeyboardKey.keyV,
control: true,
shift: true,
);
typedef TerminalClipboardWriter = Future<bool> Function(
String text, {
required bool userInitiated,
});
class TerminalClipboardNoticeRequest<T> {
const TerminalClipboardNoticeRequest({
required this.source,
required this.text,
required this.persistAllowed,
});
final T source;
final String text;
final bool persistAllowed;
String get actionKey => persistAllowed ? 'Enable' : 'Copy to clipboard';
String get negativeActionKey => persistAllowed ? 'Decline' : 'Dismiss';
}
const kTerminalClipboardNoticeMessageKey = 'terminal-clipboard-write-tip';
class TerminalClipboardNoticeCoordinator<T> extends ChangeNotifier {
TerminalClipboardNoticeRequest<T>? _current;
bool _noticeVisible = false;
bool _actionInProgress = false;
TerminalClipboardNoticeRequest<T>? get current => _current;
bool get canClaimAction =>
_noticeVisible && !_actionInProgress && _current != null;
TerminalClipboardNoticeRequest<T>? currentForSource(T source) {
final current = _current;
if (current == null || current.source != source) return null;
return current;
}
TerminalClipboardNoticeRequest<T>? recordBlocked({
required T source,
required String text,
required String option,
required bool Function(T source) canWrite,
}) {
if (!canWrite(source)) return null;
final requestAllowsPersistence =
option == kTerminalClipboardWriteUnconfigured;
if (option != kTerminalClipboardWriteAllowed && !requestAllowsPersistence) {
return null;
}
if (_noticeVisible && _actionInProgress) return null;
final wasVisible = _noticeVisible;
final persistAllowed =
wasVisible ? _current?.persistAllowed : requestAllowsPersistence;
final request = TerminalClipboardNoticeRequest(
source: source,
text: text,
persistAllowed: persistAllowed ?? requestAllowsPersistence,
);
_current = request;
if (wasVisible) return null;
_noticeVisible = true;
return request;
}
TerminalClipboardNoticeRequest<T>? claimCurrentAction() {
if (!canClaimAction) return null;
final current = _current;
if (current == null) return null;
_actionInProgress = true;
notifyListeners();
return current;
}
void releaseAction() {
if (!_actionInProgress) return;
_actionInProgress = false;
notifyListeners();
}
bool beginClose() {
if (!_noticeVisible) return false;
_actionInProgress = true;
notifyListeners();
return true;
}
void noticeClosed() => clear();
void clear() {
_current = null;
_noticeVisible = false;
_actionInProgress = false;
}
}
Future<bool> writeTerminalClipboard(
String text, {
bool userInitiated = false,
}) =>
writeTerminalClipboardPlatform(text, userInitiated: userInitiated);
Future<bool> completeTerminalClipboardWrite({
required String clipboardText,
required bool Function() canWrite,
required TerminalClipboardWriter writeClipboard,
Future<void> Function()? persistAllowed,
}) async {
if (!canWrite()) return false;
if (!await writeClipboard(clipboardText, userInitiated: true)) return false;
await persistAllowed?.call();
return true;
}
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
final platform = defaultTargetPlatform;
if (platform == TargetPlatform.linux) {
return {
for (final entry in defaultTerminalShortcuts.entries)
if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV))
entry.key: entry.value,
_controlShiftVPasteShortcut:
const PasteTextIntent(SelectionChangedCause.keyboard),
};
}
if (platform != TargetPlatform.windows &&
platform != TargetPlatform.android) {
return null;
}
return {
for (final entry in defaultTerminalShortcuts.entries)
if (!_isControlShortcut(
entry.key,
LogicalKeyboardKey.keyC,
shift: true,
))
entry.key: entry.value,
};
}
bool _isControlShortcut(
ShortcutActivator shortcut,
LogicalKeyboardKey key, {
bool shift = false,
}) =>
shortcut is SingleActivator &&
shortcut.trigger == key &&
shortcut.control &&
shortcut.shift == shift &&
!shortcut.alt &&
!shortcut.meta;
FocusOnKeyEventCallback terminalCopyHandler(
Terminal terminal,
TerminalController controller, {
FocusOnKeyEventCallback? fallback,
}) =>
(focusNode, event) {
if (_isSelectionCopyShortcut(event)) {
final selection = controller.selection;
if (selection != null && !selection.isCollapsed) {
if (event is KeyDownEvent) {
final text = terminal.buffer.getText(selection);
unawaited(writeTerminalClipboard(text, userInitiated: true));
}
return KeyEventResult.handled;
}
}
return fallback?.call(focusNode, event) ?? KeyEventResult.ignored;
};
bool _isSelectionCopyShortcut(KeyEvent event) {
final keyboard = HardwareKeyboard.instance;
final platform = defaultTargetPlatform;
final usesControlCopy =
platform == TargetPlatform.windows || platform == TargetPlatform.android;
return usesControlCopy &&
(event is KeyDownEvent || event is KeyRepeatEvent) &&
event.logicalKey == LogicalKeyboardKey.keyC &&
keyboard.isControlPressed &&
!keyboard.isShiftPressed &&
!keyboard.isAltPressed &&
!keyboard.isMetaPressed;
}

View File

@@ -7,8 +7,41 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/main.dart';
import 'package:xterm/xterm.dart'; import 'package:xterm/xterm.dart';
import 'input_modifier_utils.dart';
import 'model.dart'; import 'model.dart';
import 'platform_model.dart'; import 'platform_model.dart';
import 'rustdesk_terminal.dart';
import 'terminal_copy_shortcut.dart';
import 'terminal_mouse_handler.dart';
bool canConfigureTerminalClipboardPermission({
required bool settingsDisabled,
required bool optionFixed,
}) =>
!settingsDisabled && !optionFixed;
bool canHandleTerminalClipboardWriteRequest({
required String localOption,
required bool canConfigurePermission,
}) =>
canConfigurePermission || localOption == kTerminalClipboardWriteAllowed;
TerminalClipboardWritePermission terminalClipboardWritePermission(
String localOption, {
required bool remoteClipboardEnabled,
bool canRequestConsent = true,
}) {
if (!remoteClipboardEnabled) {
return TerminalClipboardWritePermission.denied;
}
if (localOption == kTerminalClipboardWriteAllowed) {
return TerminalClipboardWritePermission.allowed;
}
if (localOption == kTerminalClipboardWriteUnconfigured && canRequestConsent) {
return TerminalClipboardWritePermission.unconfigured;
}
return TerminalClipboardWritePermission.denied;
}
class TerminalModel with ChangeNotifier { class TerminalModel with ChangeNotifier {
final String id; // peer id final String id; // peer id
@@ -22,7 +55,25 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false; bool _disposed = false;
/// Callback to check whether Ctrl modifier lock is currently active.
/// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02).
bool Function()? isCtrlLocked;
/// Callback to clear Ctrl lock after a key is pressed (one-shot mode).
void Function()? clearCtrlLock;
/// Callback to check whether Alt modifier lock is currently active.
bool Function()? isAltLocked;
/// Callback to clear Alt lock after a key is pressed (one-shot mode).
void Function()? clearAltLock;
final _inputBuffer = <String>[]; final _inputBuffer = <String>[];
/// Exposes buffered input only for lifecycle regression tests.
@visibleForTesting
int get debugBufferedInputCount => _inputBuffer.length;
// Buffer for output data received before terminal view has valid dimensions. // Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete. // This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[]; final _pendingOutputChunks = <String>[];
@@ -41,7 +92,14 @@ class TerminalModel with ChangeNotifier {
/// The listener (typically TerminalPage) can use this to auto-close the tab/page. /// The listener (typically TerminalPage) can use this to auto-close the tab/page.
VoidCallback? onClosed; VoidCallback? onClosed;
ValueChanged<String>? onClipboardWriteBlocked;
ValueChanged<String>? onClipboardWriteSucceeded;
Future<void> _handleInput(String data) async { Future<void> _handleInput(String data) async {
// xterm can complete asynchronous input after the Flutter page has gone
// away. Stop before reading or clearing widget-owned modifier state.
if (_disposed) return;
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
// - Peer Windows: '\r' works, '\n' is just a newline. // - Peer Windows: '\r' works, '\n' is just a newline.
@@ -49,13 +107,44 @@ class TerminalModel with ChangeNotifier {
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'. // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
// - Peer macOS: same as Linux, raw-mode apps expect '\r' // - Peer macOS: same as Linux, raw-mode apps expect '\r'
// (https://github.com/rustdesk/rustdesk/issues/14907). // (https://github.com/rustdesk/rustdesk/issues/14907).
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'. // So on mobile / web-mobile, normalize the original lone '\n' to '\r'
// We deliberately do not touch multi-character payloads (e.g. pasted text) // before modifier mappings. This keeps Ctrl+J mapped to LF instead of
// so embedded newlines in pasted content are preserved. // having the generated control code rewritten to CR afterward.
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); // Multi-character keyboard payloads, such as terminal escape sequences,
if (isMobileOrWebMobile && data == '\n') { // remain unchanged. Paste input follows a separate preprocessing path.
data = '\r'; final ctrlLocked = isCtrlLocked?.call() ?? false;
final altLocked = isAltLocked?.call() ?? false;
final modifiersActive = ctrlLocked || altLocked;
// Use the same predicate for transformation and consumption. Control keys
// and escape sequences must not silently consume a pending one-shot lock.
final shouldConsumeModifiers =
modifiersActive && shouldApplyTerminalInputModifiers(data);
data = prepareTerminalInputPayload(
data,
// IME soft-keyboard paste prompts currently arrive from xterm as normal
// text input with no paste-origin metadata. Keep them on the keyboard path;
// clipboard-content heuristics can misclassify ordinary typing.
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: ctrlLocked,
altLocked: altLocked,
);
if (shouldConsumeModifiers) {
if (ctrlLocked) clearCtrlLock?.call();
if (altLocked) clearAltLock?.call();
} }
return _sendInputPayload(data);
}
/// Sends an already prepared payload without applying keyboard semantics.
/// Both normal input and paste use this transport path after their source-
/// specific preprocessing has completed.
Future<void> _sendInputPayload(String data) async {
// Clipboard reads and native sends may complete after the terminal page has
// closed. Never send or re-buffer input once this model is disposed.
if (_disposed) return;
if (_terminalOpened) { if (_terminalOpened) {
// Send user input to remote terminal // Send user input to remote terminal
try { try {
@@ -74,7 +163,20 @@ class TerminalModel with ChangeNotifier {
} }
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id { TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
terminal = Terminal(maxLines: 10000); terminal = RustDeskTerminal(
maxLines: 10000,
onClipboardWrite: writeTerminalClipboard,
clipboardWritePermission: () => terminalClipboardWritePermission(
bind.mainGetLocalOption(key: kOptionAllowTerminalClipboardWrite),
remoteClipboardEnabled:
parent.ffiModel.permissions['clipboard'] != false,
canRequestConsent: onClipboardWriteBlocked != null,
),
onClipboardWriteBlocked: (text) => onClipboardWriteBlocked?.call(text),
onClipboardWriteSucceeded: (text) =>
onClipboardWriteSucceeded?.call(text),
);
terminal.mouseHandler = const WheelButtonFixMouseHandler();
terminalController = TerminalController(); terminalController = TerminalController();
// Setup terminal callbacks // Setup terminal callbacks
@@ -176,6 +278,18 @@ class TerminalModel with ChangeNotifier {
return _handleInput(data); return _handleInput(data);
} }
Future<void> pasteText(String data) async {
final payload = prepareTerminalInputPayload(
data,
source: TerminalInputSource.paste,
isMobileOrWebMobile: false,
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: false,
altLocked: false,
);
return _sendInputPayload(payload);
}
Future<void> closeTerminal() async { Future<void> closeTerminal() async {
if (_terminalOpened) { if (_terminalOpened) {
try { try {
@@ -516,6 +630,16 @@ class TerminalModel with ChangeNotifier {
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
terminal.onOutput = null;
terminal.onResize = null;
isCtrlLocked = null;
clearCtrlLock = null;
isAltLocked = null;
clearAltLock = null;
onResizeExternal = null;
onClosed = null;
onClipboardWriteBlocked = null;
onClipboardWriteSucceeded = null;
// Clear buffers to free memory // Clear buffers to free memory
_inputBuffer.clear(); _inputBuffer.clear();
_pendingOutputChunks.clear(); _pendingOutputChunks.clear();

Some files were not shown because too many files have changed in this diff Show More