Compare commits

...

95 Commits

Author SHA1 Message Date
21pages
e17efa9474 fix svt-av1 build on windows, benchmark
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-08-05 23:52:13 +08:00
rustdesk
8eda19247c feat(codec): log which AV1 encoder a session uses
Emit an explicit "AV1 encoder: svt-av1" / "AV1 encoder: aom" line at the
point the encoder is actually built, plus the reason ("i444" /
"unsupported resolution") when a 64-bit host falls back to aom for AV1.
Avoids relying on parsing Debug output or SVT's own stderr banner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wf7v1KJM6mbhiYxWSz4PH
2026-07-23 17:52:15 +08:00
rustdesk
1ac684db0d feat(codec): use SVT-AV1 for AV1 encoding on 64-bit targets
Encode AV1 with SVT-AV1 v4.2.0 (rtc + low delay + CBR) instead of libaom
on all 64-bit targets; 32-bit targets keep aom. Decoding is unchanged and
the wire format is still AV1, so old peers are unaffected.

- vcpkg overlay port pinned to v4.2.0: static build, LTO off (mixed
  toolchain safety), MSVC >= 1950 inline workaround, no forced llvm
  binutils
- low delay contract: one packet out per picture in, blocking get_packet
- bitrate and frame rate change on the fly via RATE_CHANGE_EVENT /
  FRAME_RATE_CHANGE_EVENT; VideoQoS pushes its pacing rate through the
  new EncoderApi::set_fps, applied only after a successful send
- fall back to aom for i444 (svt-av1 is 4:2:0 only), for unsupported
  resolutions and on encoder init failure
- test_av1 now gates auto AV1 on the encoder actually used, the cached
  verdict is re-keyed (SVT-Y/SVT-N) so upgrades re-measure
- unit tests: per-frame packet contract, aom cross-decode of the
  bitstream, on-the-fly event acceptance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wf7v1KJM6mbhiYxWSz4PH
2026-07-23 01:50:57 +08:00
hatterp
1c2dd71891 Translate 'Continue' to 'Kontynuuj' in Polish (#15641) 2026-07-21 22:43:09 +08:00
Kuksgauzen
5b4d6baf47 fix: wrap BackingScaleFactor in autoreleasepool to stop NSDictionary accumulation on macOS (#15623)
Signed-off-by: Viktor Kuksgauzen <vkpiar@gmail.com>
2026-07-21 05:42:43 +08:00
gateslu
7696b0ee51 fix(linux): forward forced display server to user server (#15627)
Signed-off-by: Gateslu <lyjbbq@163.com>
2026-07-20 13:24:48 +08:00
CHarris
20ab5ab0ad fix(deploy): don't wipe local id when --deploy gets an empty --id (#15633)
`rustdesk --deploy --id ""` (e.g. an unset variable in a deployment
script) deploys a blank id, then wipes the local id and unconfirms the
key through the IPC config write. The Android deploy flow already guards
an empty id (#15146); apply the same guard to the CLI, and reject an
empty id at the IPC write boundary the same way the read path was fixed
in #15626.
2026-07-20 10:58:28 +08:00
CHarris
c01300be20 fix(ipc): never adopt an empty id from the main IPC (#15626) 2026-07-20 10:22:46 +08:00
cui fliter
5f015c9da1 Translate Continue into Simplified Chinese (#15621)
Signed-off-by: cuishuang <imcusg@gmail.com>
2026-07-18 17:56:27 +08:00
RustDesk
082a5a2a4e Revert "Fix Adjust Window sizing across DPI (#15592)" (#15620)
This reverts commit 61f0944990.
2026-07-18 16:37:30 +08:00
21pages
61f0944990 Fix Adjust Window sizing across DPI (#15592)
* Fix Adjust Window sizing across DPI and fullscreen transitions

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

* Use visible screen frame for Adjust Window

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

* Tolerate floating-point errors in Adjust Window sizing

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

* Fix review, fix Adjust Window async metric guards

Capture the current screen before awaiting window geometry so one target-frame calculation uses consistent screen metrics.

Return early when adjusting without a context and the Flutter view list is empty, instead of calling views.first after the window or engine may have been torn
    down.

Clarify the platform coordinate units used for Adjust Window scaling.

* Fix Adjust Window for maximized Linux windows

Unmaximize Linux remote windows before applying Adjust Window because native setFrame may be ignored while the window is maximized.

* Fix Adjust Window screen refresh guards

Refresh screen metrics before checking Adjust Window availability and again after exiting fullscreen so target-frame calculation uses current window geometry.

Hide Adjust Window on web because resizing relies on desktop window APIs.

* Fix review, handle missing window frame in Adjust Window

Return null when WindowController.getFrame fails so Adjust Window availability checks and resize attempts skip cleanly if the window is hidden or disposed.

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-07-18 16:32:58 +08:00
21pages
96e2a330b8 restrict switch sides to remote desktop sessions (#15610)
* fix: restrict switch sides to remote desktop sessions

 Reject switch sides requests outside authenticated remote desktop sessions, and reject switch sides responses that try to carry non-remote login types.

 Add scope coverage so file transfer, terminal, view camera, and port forward sessions cannot use switch sides.

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

* fix review: consume switch sides UUID before rejecting response

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-07-17 15:52:49 +08:00
21pages
5abf4e9724 Fix disabled installation bypass (#15598)
* Fix disabled installation bypass

Prevent install.exe and --install from opening the install flow when disable-installation is set.

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

* Refine disabled installation handling for portable clients

Document why --install must be filtered from both Rust and Flutter runner arguments for portable wrappers such as no-install.exe. Remove redundant UI-
    layer installation checks because the install entry points are already gated upstream.

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-07-16 16:00:38 +08:00
jinqiang zhang
cf2b28faf9 fix linux llvm22 build (#15565)
bindgen-0.65 is incompatible with llvm 22, we should upgrade to a
newer bindgen version

error message:

```
error[E0609]: no field `g_w` on type `vpx_codec_enc_cfg`
  --> libs/scrap/src/common/vpxcodec.rs:66:19
   |
66 |                 c.g_w = config.width;
   |                   ^^^ unknown field
   |
   = note: available field is: `_address`
```
2026-07-15 13:27:15 +08:00
fufesou
bdb38c4730 fix: check valid id (#15535)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-14 15:35:52 +08:00
Maison da Silva
fa418cace6 Translate 'Continue' to 'Continuar' in ptbr.rs (#15567)
Translate 'Continue' to 'Continuar' in ptbr.rs
2026-07-13 18:56:41 +08:00
rustdesk
94a2a2bb4a fix https://github.com/rustdesk/rustdesk/issues/15566 2026-07-11 21:43:04 +08:00
Maison da Silva
865fe71c46 Update Portuguese translations for clarity (#15534) 2026-07-11 17:18:12 +08:00
rustdesk
137298e05a revert back 2026-07-10 15:21:48 +08:00
rustdesk
685a89a171 target Android 15 2026-07-10 15:12:06 +08:00
RustDesk
12b5cc7f72 Revert "fix: ci: macos: allow signed but not notarized dmg (#15530)" (#15551)
This reverts commit 29e1852a68.
2026-07-10 11:47:13 +08:00
Vasyl Gello
480e9e8234 Fix default Android API version mismatch between vcpkg and rest of build (for working on android 6) (#14850)
* Fork vcpkg triplets to keep Android API version on 21

Fixes crash on API platforms 21 to 23 due to missing
symbol `__write_chk` (available since API 24).

Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>

* flutter/build_android_deps.sh: Refactor to remove unused

... variables and shellcheck warnings.

Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>

---------

Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>
2026-07-10 11:38:43 +08:00
Zhenyu FU
29e1852a68 fix: ci: macos: allow signed but not notarized dmg (#15530)
* fix: ci: macos: allow signed but not notarized dmg
Signed-off-by: Zhenyu FU <ysfcore@outlook.com>

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

* merge notarize checking to existing steps
Signed-off-by: Zhenyu FU <ysfcore@outlook.com>
2026-07-09 15:23:43 +08:00
fufesou
8314335b31 fix: update download, force tls (#15529)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-09 15:05:49 +08:00
bovirus
acb9f63e1d Update it.rs (#15531) 2026-07-08 16:35:34 +08:00
VenusGirl❤
005a8b4a04 Update Korean (#15525)
Updated Korean translations for clarity and accuracy.
2026-07-08 11:09:39 +08:00
RustDesk
e2149974cc harden wf_cliprdr.c (#15515)
* harden wf_cliprdr.c

* fix copilot review

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix review

* fix review

* condense hardening comments, fix style in wf_cliprdr.c

Comment-only cleanup of the review-justification comments; also move
the mutex wait result declaration to the top of the block and fix
continuation-line indentation. No behavior change.

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

* add invariant tests for file contents request/response hardening

Cover the zeroed optional request fields, stream ID filtering,
oversized/NULL response rejection and the zero-byte EOF path.

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

* address copilot review findings in wf_cliprdr.c

- Reject a negative FILECONTENTS_SIZE result: m_lSize is unsigned, so a
  negative value became a huge bogus stream size that keeps reads going.
- Use a unique per-stream counter as the CLIPRDR streamId instead of a
  truncated IStream pointer, which could collide or be reused after free
  (and leaked heap addresses to the peer).
- Add req_f_request_mutex to serialize whole file-contents request/response
  cycles, enforcing the previously assumed one-outstanding-request
  invariant when multiple streams are read concurrently. Bounded acquire
  so a wedged request fails the read instead of hanging a consumer.

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

* serialize file-contents request state and poison streams after timeout

- Extract lock_mutex() for the WAIT_OBJECT_0/WAIT_ABANDONED idiom shared by
  take_req_fdata, the request-serialization acquire, and the response handler.
- Collapse the acquire/send/take/release cycle into
  cliprdr_request_filecontents_sync(), used by CliprdrStream_Read and the size
  probe in CliprdrStream_New.
- Publish req_f_stream_id_expected/req_f_size_requested under req_f_mutex in the
  sender and read them under the same lock in the response handler, removing the
  cross-thread data race on those fields.
- Poison a stream (m_failed) after a request fails/times out, so a late response
  carrying a previous offset's bytes cannot satisfy a later same-stream read.

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

* key the responder stream cache on connID as well as streamId

Per-stream ids restart from 1 in each peer process, so two connections can
emit the same streamId. The process-static pStreamStc cache keyed only on
streamId could then serve one peer the IStream cached for another peer (a
different file), silently returning wrong-file bytes. Add connID to the key.

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

* harden the format-data path against late/duplicate responses

The format-data rendezvous had the same single-slot race the file-contents
path just fixed: the channel thread rewrote clipboard->hmem with no lock while
explorer-thread consumers read/freed it, nothing serialized concurrent
requests, and no flag told an expected response from a stray one.

- Add format_request_mutex (serializes the whole request/response cycle) and
  hmem_mutex (guards the hmem hand-off and formatDataRespExpected).
- cliprdr_send_data_request now takes ownership of the response buffer under
  hmem_mutex and returns it to the caller, so a later response cannot touch a
  buffer a consumer is using. All three consumers (GetData, WM_RENDERFORMAT,
  DELAYED_RENDERING) and the WM_CLIPBOARDUPDATE cleanup use the returned/taken
  handle instead of the shared slot.
- The response handler drops any response arriving while formatDataRespExpected
  is clear (late/duplicate/unsolicited), consumes the flag on the first
  response, and no longer dereferences a NULL clipboard in the SetEvent path.

Pre-existing issue, not introduced by this branch; generalizes the
file-contents hardening to the format-data path.

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

* remove the dedicated wf-cliprdr CI workflow

Drop .github/workflows/wf-cliprdr-ci.yml on this branch as requested.

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

* remove wf-cliprdr invariant tests

Drop tests/test_invariant_wf_cliprdr.c on this branch as requested.

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

* refactor and simplify, remove mutex which is dangeours

* fix copilot false report

* fix review

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:54:22 +08:00
rustdesk
6c578292e8 bump to 1.4.9 2026-07-06 18:00:39 +08:00
fufesou
28930c0463 fix: non-E2EE show dialog (#15514)
* fix: non-E2EE show dialog

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

* fix: build web, bridge

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

* fix: direct IP access, do not snow non-E2EE dialog

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

* fix: non E2EE dialog, update contents

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

* fix: non-E2EE, show dialog, port forward

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

* fix: non-E2EE dialog, port forward, ignore direct IP access

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

* fix: non-E2EE is_direct_ip_access()

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

* Simple refactor

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

* fix: non-E2EE dialog, port forward, close socket on disconnect

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

* fix: non-E2EE dialog, incorrect reuse of Data::Close

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-06 17:05:11 +08:00
fufesou
37141afece refact: remove feature cli (#15524)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-06 16:17:32 +08:00
fufesou
493b14ba78 Fix/session scope permission audit (#15469)
* fix: enforce session-scoped permissions

Restrict non-remote sessions to their allowed message types, filter
out-of-scope login options, and audit rejected or filtered messages.
Hide screenshot controls outside default remote sessions.

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

* fix: typo

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

* fix: prevent privacy mode in view-camera sessions

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

* fix: switch display, check non-view-camera

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

* fix: avoid sending unsupported messages

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

* fix: session scope, add option to control close/alarm

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

* Fix: scoped session handling for view-camera compatibility

  - Skip view-camera auto-login and display-management side effects
  - Allow harmless render broadcasts without affecting non-video sessions
  - Keep legacy view-camera management messages compatible as no-ops
  - Preserve stricter scope violations for non-video session types

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

* update libs/hbb_common

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

* fix: ignore repeated login request

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

* fix: view camera, support "Take screenshot"

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

* fix: session scoped messages, check update options

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

* fix: session scope, check portforward before conn type voolations

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

* fix: scoped messages, reduce changes.

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

* fix: session scope, comments

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

* fix: keep scoped sessions compatible with render broadcasts

Allow legacy render-broadcast no-op messages for file transfer and terminal
sessions while keeping port forward and mixed options scoped. Also avoid sending
new render updates to non-video Flutter sessions.

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

* fix: scope screenshot requests by video source

Key screenshot requests by video source and display index so camera and
monitor sessions cannot consume each other's requests. Deduplicate the Flutter
render-target predicate while keeping render updates limited to video sessions.

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

* fix: Harden scoped session message handling

Filter option updates by authenticated connection type,
keep legacy no-op messages compatible, and avoid noisy repeated
scope violation alarms.

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

* fix: session scope, comments

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

* fix: Send close reason for scoped session violations

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

* fix: Enforce scoped session message filtering

  - filter out-of-scope messages for limited session types
  - scope option updates by authenticated connection type
  - keep render-broadcast no-op compatibility for non-video scoped sessions
  - restore view-camera screenshot handling
  - improve session scope violation audit labels
  - avoid cloning option messages on the remote hot path

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

* Fix scoped session clipboard broadcast compatibility

Treat text clipboard broadcasts as no-op compatibility messages for FileTransfer and Terminal sessions, matching existing
handler behavior and preventing optional scope-violation close from disconnecting those sessions. Keep ViewCamera and
PortForward clipboard messages subject to normal scope enforcement.

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

* fix: log warn

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

* fix: restrict Flutter clipboard sync to default sessions

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

* fix: session scope, comments and tests

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

* fix: session scope, reset sessions in login handle

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

* fix: session scope, view camera, allow clipboard noop

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-05 23:28:32 +08:00
StealUrKill
cf1de4de62 Feature: Restore the last viewed monitor on auto reconnect (#15441)
* Feature: Restore the last viewed monitor on auto reconnect

Remembers the users last manually selected remote monitor and returns to it after an auto reconnect.

In memory, reconnect only, and bounds checked against the current display count.

It is skipped in "use all my displays" mode.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

* Address review on reconnect monitor restore

Avoid a crash if the session closes during a reconnect.

Don't overwrite the remembered monitor on auto restore.

Defer the switch until the view is ready so a monitor with a different size renders correctly.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

* Guard all-displays reconnect restore against empty display list

* Harden reconnect monitor restore against races and multi-UI sessions

Cancel a queued restore when the user manually selects a monitor, so a
newer choice is not overridden by a stale pending restore.

Compare the remembered monitor against the reconnect event's display
instead of the stale _pi.currentDisplay, which is intentionally left
unchanged when the peer has multiple sessions.

Add a frame-independent fallback so a multi-UI tab that never receives
the first-image event (its display is filtered to the owning tab) still
restores the remembered monitor.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

* Harden reconnect monitor restore: fallback timer, lifecycle, cursor

Follow-up hardening on the auto-reconnect monitor restore:

- Cancel the fallback timer synchronously once this tab owns the restore,
  so it can no longer fire while onEvent2UIRgba is awaiting canvas setup
  and switch displays before the canvas is ready (the offset the deferred
  restore exists to avoid). The multi-UI no-frame fallback stays intact.
- Apply the restore in a finally so a throwing canvas init still runs it
  instead of stranding a queued restore with the timer already cancelled.
- Cancel the fallback timer on a manual monitor switch, so a newer user
  selection supersedes a queued restore instead of racing it.
- Restore with updateCursorPos: false, matching other programmatic
  display switches so an auto-restore does not reposition the cursor.

---------

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
2026-07-05 22:32:47 +08:00
fufesou
9fdb8410d3 fix: parse exit code of flutter web (#15501)
* fix: parse exit code of flutter web

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

* fix: exit-code, debug print

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-03 12:49:32 +08:00
alonginwind
a2b79462ab fix: auto-close terminal tab/window when shell exits (#15448) 2026-07-02 16:43:27 +08:00
fufesou
dce221be5a fix(clipboard): make CLIPRDR format-map growth checked (#15493)
* fix(clipboard): make CLIPRDR format-map growth checked

The Windows CLIPRDR format-list handler relies on map_ensure_capacity()
while processing peer-provided formats. The previous helper only attempted
growth: if realloc() failed, it returned silently and the caller continued
processing. A later iteration could then index past the allocated
format_mappings array.

Make format-map growth a checked operation. The handler now validates the
peer-provided format count, ensures the mapping array is large enough before
writing entries, and aborts processing if growth fails. Newly allocated slots
are zeroed so existing cleanup can safely run after partial processing.

Also bound remote format names before measuring/converting them. The chosen
limits follow Windows clipboard/atom constraints:
  - registered clipboard format IDs use 0xC000..0xFFFF
  - string atom names are limited to 255 bytes

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

* fix(clipboard): reject invalid remote format-list entries

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-07-02 16:18:07 +08:00
rustdesk
9d1ab3fba3 fix the AOM tile-control argument type 2026-07-01 11:47:08 +08:00
rustdesk
b3bd18845d update hbb_common 2026-06-30 11:29:56 +08:00
rustdesk
435f6ec61d update copyright 2026-06-30 11:02:28 +08:00
21pages
0497814004 Add authentication details to connection audit (#15456)
* Add authentication details to connection audit

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

* rename normalize_conn_audit_primary_auth to normalize_conn_audit_auth_fields

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

* Merge permanent password audit methods

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

* Simplify connection audit auth methods

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-06-29 16:04:24 +08:00
劉清揚
4b1ef9e20d fix(android): sync input service state with Flutter (#15419)
Signed-off-by: liuqiang <2465199797@qq.com>
2026-06-29 15:14:34 +08:00
twprh
10d5250d23 Update flutter-build.yml (#15454) 2026-06-28 17:18:41 +08:00
Maison da Silva
2ee580d49d Update translation for outdated installation message (#15427)
Update translation for outdated installation message
2026-06-28 12:11:03 +08:00
fufesou
4a54029cac fix(update): msi, norestart (#15440)
* fix(update): msi, norestart

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

* fix(update): escape path

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-27 16:45:27 +08:00
fufesou
001848bf2f fix(fuse): umount (#15426)
* fix(clipboard): clean up stale Linux FUSE mounts

Recover Linux file clipboard FUSE mount points before remounting and stop treating a cached
context as valid when the mount has already gone away.

This fixes the desktop file manager copy failure that shows dialogs such as
"Error while copying a" and "There was an error copying the file into xxx".

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

* fix(clipboard): fuse, reduce dups

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

* fix: clear Linux file clipboard before unmounting FUSE

Ensure Linux client teardown clears RustDesk file clipboard URLs while
the FUSE context is still available. Also prefer fusermount before
umount to avoid noisy unprivileged teardown attempts.

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

* fix(clipboard): return and log errors

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-26 16:17:44 +08:00
21pages
989bf80fe8 Support controller user attribution in audit logs (#15407)
* Support controller user attribution in audit logs

This PR supports associating audit logs with the controller user.

  ## Implementation:
  - Add `ControlledContext { conn_audit_token }` to `PunchHole`, `RequestRelay`, and `FetchLocalAddr`.
  - The server sends a controller-user identity snapshot to the controlled client through rendezvous messages.
  - The controlled client sends the token back to the server when posting the `on_open` conn audit or IP whitelist alarm audit.
  - This lets the server attach the controller user to audit logs.

  ## How the controlled client helps identify the controller user:
  - Conn audit: sends the token to the server in `on_open`; the server creates the audit log and caches the user snapshot.
  - File audit: sends `id` and `conn_id`; the server uses them to find the cached user snapshot.
  - Alarm audit: IP whitelist sends the token directly; other alarm logs send `id` and `conn_id`, and the server uses them to find the cached user
  snapshot.

  ## Compatibility:
  - Supported only for logs created with a new server and a new controlled client.
  - Does not require upgrading the controller client.

  ## Test

  - [x] New/old clients connected to new/old servers, and conn/file/alarm audit logs worked normally.
  - [x] New client connected to new server generated searchable conn/file/alarm audit logs.
  - [x] Punch hole, local addr, and relay paths worked with audit logs and control role on new/old servers.
  - [x] Direct IP connections produced audit logs, but do not support user audit.

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

* rename conn_audit_token to conn_audit_ref

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-06-26 15:07:27 +08:00
VenusGirl❤
78b5f47668 Update ko.rs (#15395) 2026-06-26 13:51:08 +08:00
jkh0kr
97e9e44faa Update ko.rs (#15390)
Incorrect translation
2026-06-26 11:19:18 +08:00
RAIT-09
ff226f6d80 fix(clipboard): unix, refresh cached file size/mtime on re-copy (#15392)
* fix(clipboard): unix, refresh cached file size/mtime on re-copy

sync_files() deduped re-copies by path string only, so editing a file
and re-copying it (same path) skipped refreshing the cached size/mtime
and the file-group descriptor; the peer then received the file
truncated to the old cached size (silent corruption for PDF/zip/pptx).
Widen the early-return guard to also compare a top-level (size, mtime)
fingerprint and to always rebuild when a directory is selected. The
Windows wf_cliprdr.c path re-stats per request and is unaffected.

Signed-off-by: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>

* opt(clipboard): unix, compute file fingerprint once and pass into sync_files

fingerprint() was computed before taking the CLIP_FILES lock and then
recomputed inside ClipFiles::sync_files under the lock. Pass the precomputed
value in so the top-level stat runs once and outside the critical section.
No behavior change.

Signed-off-by: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>

---------

Signed-off-by: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
2026-06-25 09:50:33 +08:00
Daniel Marschall
0cbdb6ffb3 Fix tray icon click (regression due to breaking change in tray-icon 0.17) (#15413) 2026-06-25 09:43:04 +08:00
fufesou
b8117c5c34 fix(fuse): fuse path broken, since ipc path changed (#15406)
* fix(fuse): fuse path broken, since ipc path changed

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

* fix(fuse): init, handle error

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

* fix(fuse): unmount attempt on newly created directory failed

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-24 16:52:27 +08:00
Maison da Silva
a69614d464 Update translation for 'Control Actions' in ptbr.rs (#15386) 2026-06-24 12:26:36 +08:00
fufesou
58ee593e26 fix(custom-client): show options, incoming-only (#15394)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-24 00:26:09 +08:00
fufesou
09bc9056c9 fix(update): win aarch64 (#15389)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-23 17:59:29 +08:00
fufesou
0c6df924d1 refact: file transfer, do this for all conflicts(tasks) (#15385)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-23 11:23:42 +08:00
dependabot[bot]
456817b4f4 Git submodule: bump libs/hbb_common from e50ac3c to 387603f (#15384)
Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `e50ac3c` to `387603f`.
- [Release notes](https://github.com/rustdesk/hbb_common/releases)
- [Commits](e50ac3cd48...387603f47c)

---
updated-dependencies:
- dependency-name: libs/hbb_common
  dependency-version: 387603f47cbb15c0d3dc3d67ae3396d3eb707daf
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-23 10:28:08 +08:00
rustdesk
16570ee34f fix https://github.com/rustdesk/rustdesk/discussions/15355 2026-06-22 22:57:08 +08:00
just-some-tall-bloke
2b40c61d8e Fix spelling and grammar errors in comments (#15370)
- dbus.rs: fix grammar (add 'between', pluralize 'processes')
- win_impl.rs: fix typo 'hight' -> 'high', idiom 'such called' -> 'so-called'
- startwm.sh: fix typo 'loging' -> 'logging'
- lib.rs: fix copy-paste error in doc comments for scroll buttons
- message.proto: fix typo 'Clipobard' -> 'Clipboard'
2026-06-22 15:26:51 +08:00
RustDesk
dcc64cdeae cjk (#15379) 2026-06-22 14:47:12 +08:00
RustDesk
2747d3d8b4 Revert "fix(arm64-linux): fix CJK font rendering on flutter-elinux (#15324)" (#15377)
This reverts commit c9391fb894.
2026-06-22 13:40:39 +08:00
fufesou
3c574a4182 fix(wayland): clipboard, support ext-data-control (#15366)
* fix(wayland): clipboard, support ext-data-control

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

* fix(clipboard): restart stale listener and log join panics

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

* update clipboard-master

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

* refactor(clipboard): remove redundant stale listener cleanup

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-21 17:09:41 +08:00
Tigah
311d4708e5 fix(linux): reap a crashed headless session's leftovers on next start (#15348)
The teardown cleanup added for #15183 only runs on a clean disconnect.
If the service or its --server crashes before then, the headless logind
session scope and the /tmp/.X<n> lock files it created leak the same way
#15183 leaked them, with nothing to reclaim them afterwards.

Record the session scope and display when the headless session starts,
and on the next --server start reap exactly what the previous run
recorded, then drop the marker. It only ever touches the one scope and
display the previous run recorded, never a scan, so unrelated sessions
are untouched; the reap and X cleanup reuse the teardown path.

A logind session id is only unique within a boot: the counter lives in
/run and resets, so a recorded "session-N.scope" can name a different,
live session after a reboot. Tag the marker with the boot id and only
reap the scope when it matches the current boot. A leaked cgroup cannot
outlive a reboot, so nothing legitimate is lost cross-boot; the X lock
cleanup stays pid-guarded and runs either way.

Signed-off-by: TBX3D <88289044+TBX3D@users.noreply.github.com>
2026-06-21 16:56:41 +08:00
Maison da Silva
5cf4323d07 Fix Portuguese translations for consistency (#15354) 2026-06-21 16:52:28 +08:00
bovirus
3976701ac6 Update Italian translations (#15367) 2026-06-21 16:52:13 +08:00
fufesou
9ded8d6ab2 fix(keyboard): win, key, Pause (#15351)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-20 21:11:19 +08:00
rustdesk
cd2fff0655 fix hbb_common 2026-06-20 18:31:42 +08:00
rustdesk
10f61ffdc2 translate 2026-06-20 18:27:42 +08:00
Tigah
d72952bf93 fix(linux): clean up leftover session procs and X locks on headless teardown (#15337)
On headless login the desktop manager opens a PAM session, which makes
pam_systemd register a logind session and put the spawned Xorg + window
manager and their children (e.g. pipewire) in a "session-<id>.scope"
cgroup. Teardown only killed the Xorg and wm pids, so the rest of the
session kept running, holding the logind session in "closing" and leaking
runtime sockets and X display numbers on every reconnect.

Capture the session scope cgroup from a child pid and, on teardown, kill the
remaining processes in it and any descendant cgroups (cgroup.procs is not
recursive, and a desktop may move pipewire and apps into child scopes),
excluding our own service process and anything tracked in CHILD_PROCESS
together with its descendants. The connection manager is a sudo child, so the
tracked pid is the wrapper while the real --cm-no-ui worker may be a descendant
(sudo with use_pty runs it under a monitor); both can share the scope when
their PAM stack does not re-home them.

Xorg is killed with SIGKILL, so it also leaves its "/tmp/.X<n>-lock" and
"/tmp/.X11-unix/X<n>" behind; get_avail_display() treats either file as the
display being in use, so the number is never reused and climbs until the
range is exhausted. Remove those files for the session's display on
teardown, as a clean Xorg exit would.

Closes #15183

Signed-off-by: TBX3D <88289044+TBX3D@users.noreply.github.com>
2026-06-20 14:21:42 +08:00
Re*Index. (ot_inc)
a7c55db9ac Fix Japanese text (#15345) 2026-06-20 09:50:04 +08:00
StealUrKill
bff47e2b81 Feature: Add monitor-switch buttons to remote toolbars (#15342)
* Feature: add monitor-switch buttons to remote toolbars

Add buttons to cycle through the remote displays from the toolbars:

- A main-toolbar button and a minimized-handle button, both using a shared SVG icon with the current monitor number overlaid.
- Two opt-in settings under Settings/Other. The minimized-toolbar option is nested under the main-toolbar option.
- The minimized button only appears once the toolbar is collapsed.
- Cycling does not move the remote cursor, matching the existing in-toolbar monitor buttons.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

* fix: respect individual-window display mode when cycling

In "Show displays as individual windows" mode, route the cycle button through openMonitorInNewTabOrWindow like the monitor selector, so each display keeps its own window instead of repurposing the current one.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

---------

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
2026-06-20 01:22:20 +08:00
fufesou
3d478c4935 fix(ios): mouse mismatch (#15339)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-19 20:50:56 +08:00
RustDesk
a658e987b7 Revert "Feature: Add monitor-switch buttons to remote toolbars (#15314)" (#15338)
This reverts commit 7c8b0adc1e.
2026-06-19 12:34:59 +08:00
StealUrKill
7c8b0adc1e Feature: Add monitor-switch buttons to remote toolbars (#15314)
* Feature: add monitor-switch buttons to remote toolbars

Add a one-click "switch to next monitor" control to both desktop toolbars:
- Main toolbar: always shown when the remote has more than one monitor,
  styled to match the existing blue icon buttons (white screen, black number).
- Minimized (draggable show/hide) toolbar: off by default, toggled via a new
  "Show monitor switch on minimized toolbar" checkbox in the Display menu and
  persisted as a local option.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

* Update remote_toolbar.dart

* refact: unify monitor-switch button icons, share tooltip

Addressing the review feedback on the monitor-switch toolbar buttons:

- Add assets/display_switcher.svg and use it for both the main and minimized buttons. This replaces the hand-drawn glyph (Containers + magic numbers) on the main toolbar. The icon scales with DPI/theme and the two toolbars stay visually consistent.
- Flip the minimized button's label to white for contrast, since the new icon has a solid screen.
- Move the tooltip string into a shared _MonitorCycle.tooltip getter so both buttons use one source of truth.
- Use const Offstage() for consistency with the surrounding returns.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

* Improve monitor-switch settings and toolbar behavior

- Nest the minimized-toolbar option under the main one in settings only show when the main option is enabled.

- Only show the minimized switch button on the collapsed toolbar handle, so it no longer duplicates the main switch while the toolbar is expanded.

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>

---------

Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-06-19 11:19:08 +08:00
fufesou
a732ebc3e1 fix: win, ci, update RustDeskTempTopMostWindow (#15334)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-19 00:24:48 +08:00
fufesou
30c0867e40 fix(ci): win arm64 (#15333)
* fix(ci): win arm64

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

* fix: ci, less changes

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

---------

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

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

* Upgrade Flutter to 3.44.0 and introduce Windows arm64 in CI

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

* Bump bridge build to Flutter 3.44 as well

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

* Fix install flutter step for Win arm64

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

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

* Fix Flutter installations on Win

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

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

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

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

* Enable hwcodec feature now that upstream supports building it

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

* CI: improve logic for getting Flutter arm64 SDK

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

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

* Exclude MSI build on arm64

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

* Address PR feedback

* Update Cargo.toml

* Update Cargo.lock

* Update Cargo.lock

* Add Flutter 3.44 DialogThemeData background colors

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

---------

Signed-off-by: Dennis Ameling <dennis@dennisameling.com>
Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-06-18 22:37:15 +08:00
fufesou
0797ebb695 Refact/privacy mode 1 multi monitors (#15321)
* refact: privacy mdoe 1, multi-monitors

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

* fix: harden privacy mode overlay & capture cleanup

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

* Fix privacy mode edge cases after multi-monitor overlay changes

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

* Add missing changes

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-18 21:27:44 +08:00
Bia503
c9391fb894 fix(arm64-linux): fix CJK font rendering on flutter-elinux (#15324)
* fix(arm64-linux): fix CJK font rendering on flutter-elinux

The flutter-elinux engine used for ARM64 Linux builds is compiled without
--enable-fontconfig, so Flutter's text shaper cannot discover system fonts.
This causes CJK characters to render as tofu boxes even when fonts such as
Noto Sans CJK are installed. See flutter/flutter#139293.

Fix by loading a CJK font at startup via FontLoader (bypassing fontconfig)
and propagating it through two paths so all text widgets are covered:

1. MyTheme.applyFontFallback() — updates textTheme on both light and dark
   ThemeData so Material components receive the fallback through the theme.

2. _mergeCjkFallback() in GetMaterialApp builders — wraps child widgets in
   DefaultTextStyle.merge so bare Text() widgets and those with inherit:true
   also render CJK characters correctly.

Font discovery queries fc-list for zh, ja, and ko separately, preferring
fonts present in all three sets (true pan-CJK fonts such as NotoSansCJK or
SourceHanSans) over Chinese-only fonts that may lack Japanese kana or Korean
hangul glyphs.  Falls back to a hardcoded search-path list covering
Debian/Ubuntu, Fedora/RHEL, Arch Linux, and WenQuanYi font layouts.

This is an app-level workaround. The engine-level fix is tracked at
flutter/flutter#180235 (open as of 2026-06).

Fixes #10666

Signed-off-by: Bia503 <yinwenche189@gmail.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Signed-off-by: Bia503 <yinwenche189@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-18 20:47:24 +08:00
Maison da Silva
8a955888bf Fix Portuguese translations for consistency (#15325)
Fix Portuguese translations for consistency
2026-06-18 10:28:34 +08:00
21pages
36e812e550 update hwcodec (#15323)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-06-17 22:58:46 +08:00
rustdesk
8baa995c7a bump version 2026-06-17 22:18:45 +08:00
RustDesk
f4a0535289 autocomplete online (#15313)
* autocomplete online

* review fix

* review fix

* remove literalInput
2026-06-17 22:04:34 +08:00
RustDesk
6665242edf Revert "refact: privacy mdoe 1, multi-monitors (#15318)" (#15320)
This reverts commit 3cdf1cce54.
2026-06-17 21:46:40 +08:00
fufesou
3cdf1cce54 refact: privacy mdoe 1, multi-monitors (#15318)
* refact: privacy mdoe 1, multi-monitors

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

* fix: harden privacy mode overlay & capture cleanup

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-17 21:43:57 +08:00
fufesou
88ae00ba73 refact: restart remote device, autoconnect (#15290)
* refact: restart remote device, autoconnect

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

* fix: guard restart reconnect timer after session close

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

* Simple refactor

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

* fix(restart): auto connect, comments

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-17 17:50:18 +08:00
Maison da Silva
7c26575dbd Update translation for remote toolbar docking message (#15297)
* Update translation for remote toolbar docking message

Update translation for remote toolbar docking message

* Translate 'Display' to 'Tela' in Portuguese locale

* Change translation of 'Display' to 'Exibição'
2026-06-17 17:36:27 +08:00
fufesou
93d064a9b0 refact(oidc): icon azure to microsoft (#15278)
* refact(oidc): icon azure to microsoft

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

* Simple refactor

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

* refact: oidc, remove unused auth-azure.svg

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-17 14:36:53 +08:00
rustdesk
bf206dc309 fixing https://github.com/rustdesk/rustdesk/issues/15293 2026-06-16 11:36:53 +08:00
fufesou
6d116cf1c9 fix(clipboard): Windows DIB images, fill missing alpha (#15296)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-16 11:29:53 +08:00
RustDesk
1fc33218dc Revert "fix(iPad): keep touch gestures with external mouse (#14652)" (#15288)
This reverts commit 5b7ad339b8.
2026-06-15 15:13:54 +08:00
21pages
b73e5bbfa0 opt: release clipboard config lock before updates (#15277)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-06-14 17:27:19 +08:00
fufesou
78533e428e feat: theme logo (#15268)
* feat: theme logo

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

* perf(flutter): cache theme logo asset resolution

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-14 13:47:39 +08:00
fufesou
cc7fe4efdc Fix/generate py target injection (#15248)
* fix: generate.py, target injection

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

* refact: logs

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Update port_forward.rs

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-11 23:09:34 +08:00
littlestejan
84af60c07e Fix clipboard synchronization not fully disabled in View Only mode (#15224)
* fix: view-only clipboard sync

Signed-off-by: Setani <little_stejan@hotmail.com>

* fix: gate Android MultiClipboards handling with clipboard permissions

Signed-off-by: Setani <little_stejan@hotmail.com>

---------

Signed-off-by: Setani <little_stejan@hotmail.com>
2026-06-10 07:42:58 +08:00
fufesou
6426269d41 Refact/printer driver default unchecked (#15191)
* refact: installation, printer driver, default unchecked

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

* refact: silent install, get option from the reg values

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

* refact: silent install, arg printer=[0|1]

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-06 08:51:08 +08:00
dependabot[bot]
7c41f993fe Git submodule: Bump libs/hbb_common from df6badc to 387603f (#15189)
Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `df6badc` to `387603f`.
- [Release notes](https://github.com/rustdesk/hbb_common/releases)
- [Commits](df6badca5b...387603f47c)

---
updated-dependencies:
- dependency-name: libs/hbb_common
  dependency-version: 387603f47cbb15c0d3dc3d67ae3396d3eb707daf
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 14:04:02 +08:00
184 changed files with 7521 additions and 1432 deletions

View File

@@ -2,6 +2,8 @@
rustflags = ["-Ctarget-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
[target.aarch64-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]
[target.'cfg(target_os="macos")']
rustflags = [
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Applies the Flutter 3.44-only source/pubspec changes on the fly, in CI only.
#
# Windows arm64 needs Flutter >= 3.44 (the first stable release shipping an arm64 Dart SDK +
# engine), which renamed DialogTheme/TabBarTheme -> *Data and needs newer extended_text/
# google_fonts. Every other platform is still on Flutter 3.24.5, where the old names/versions
# are required, so these changes are kept OUT of the committed sources and applied here instead.
#
# Used by BOTH the Windows arm64 build (flutter-build.yml) and its dedicated bridge artifact
# (bridge.yml) so they share an identical 3.44 source state -- the generated *.freezed.dart must
# compile against the same Flutter/freezed version the arm64 build resolves.
#
# Remove this script (and commit the changes) once upstream bumps Flutter across the board.
#
# Run from the repository root. sed is used (not a git-apply patch) because the checked-out
# sources are CRLF on the windows-11-arm runner; the substitutions below are anchor-free and
# therefore CRLF-safe.
set -euo pipefail
# ThemeData API renames (Flutter 3.27+):
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 '/static ThemeData lightTheme = ThemeData(/,/static ThemeData darkTheme = ThemeData(/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
backgroundColor: Colors.white,/' flutter/lib/common.dart
sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThemeDark,/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
backgroundColor: Color(0xFF18191E),/' flutter/lib/common.dart
# Dependency bumps required by the newer Dart/Flutter:
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
# Fail loudly if any expected string drifted, so we never silently build unpatched:
grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart
grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart
grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart
grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart
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

View File

@@ -7,7 +7,6 @@ on:
env:
CARGO_EXPAND_VERSION: "1.0.95"
FLUTTER_VERSION: "3.22.3"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
@@ -18,10 +17,21 @@ jobs:
fail-fast: false
matrix:
job:
# Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3).
- {
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter-version: "3.22.3",
artifact-name: "bridge-artifact",
}
# Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel.
- {
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter-version: "3.44.0",
artifact-name: "bridge-artifact-flutter-3.44",
}
steps:
- name: Checkout source code
@@ -64,13 +74,13 @@ jobs:
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
with:
path: /tmp/flutter_rust_bridge
key: vcpkg-${{ matrix.job.arch }}
key: bridge-${{ matrix.job.flutter-version }}
- name: Install flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
flutter-version: ${{ matrix.job.flutter-version }}
cache: true
- name: Install flutter rust bridge deps
@@ -78,7 +88,15 @@ jobs:
run: |
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then
# Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution.
sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml
else
# Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the
# generated *.freezed.dart compiles against the same Flutter/freezed it resolves.
bash .github/patches/apply_flutter_3.44_source_patches.sh
fi
pushd flutter && flutter pub get && popd
- name: Run flutter rust bridge
run: |
@@ -88,7 +106,7 @@ jobs:
- name: Upload Artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bridge-artifact
name: ${{ matrix.job.artifact-name }}
path: |
./src/bridge_generated.rs
./src/bridge_generated.io.rs

View File

@@ -81,6 +81,7 @@ jobs:
# - { target: x86_64-apple-darwin , os: macos-10.15 }
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
# - { target: aarch64-pc-windows-msvc , os: windows-11-arm }
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
steps:

View File

@@ -27,6 +27,11 @@ env:
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5"
ANDROID_FLUTTER_VERSION: "3.24.5"
# Windows arm64 only: the first stable Flutter to ship a native arm64 Windows Dart SDK +
# 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
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
FLUTTER_WINDOWS_ARM_VERSION: "3.44.0"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
@@ -39,7 +44,7 @@ env:
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.7"
VERSION: "1.4.9"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -53,14 +58,24 @@ jobs:
build-RustDeskTempTopMostWindow:
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
with:
upload-artifact: ${{ inputs.upload-artifact }}
target: windows-2022
configuration: Release
platform: x64
target_version: Windows10
strategy:
fail-fast: false
matrix:
job:
- {
target: windows-2022,
platform: x64,
}
- {
target: windows-11-arm,
platform: ARM64,
}
with:
upload-artifact: ${{ inputs.upload-artifact }}
target: ${{ matrix.job.target }}
configuration: Release
platform: ${{ matrix.job.platform }}
target_version: Windows10
build-for-windows-flutter:
name: ${{ matrix.job.target }}
@@ -76,9 +91,20 @@ jobs:
target: x86_64-pc-windows-msvc,
os: windows-2022,
arch: x86_64,
flutter-arch: x64,
vcpkg-triplet: x64-windows-static,
build-args: "--vram",
}
- {
target: aarch64-pc-windows-msvc,
os: windows-11-arm,
arch: aarch64,
flutter-arch: arm64,
vcpkg-triplet: arm64-windows-static,
# vram is x86/x64-only (NVENC needs CUDA, Intel MediaSDK needs __rdtsc);
# no NV/Intel/AMD hardware exists on Windows-on-ARM, so vram stays disabled here.
build-args: "",
}
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
@@ -95,36 +121,91 @@ jobs:
- name: Restore bridge files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
# arm64 is on Flutter 3.44, so it needs the bridge generated with the same Flutter
# (its *.freezed.dart must match the freezed the arm64 build resolves). x64 and every
# other platform keep the default 3.22.3-generated bridge.
name: ${{ matrix.job.arch == 'aarch64' && 'bridge-artifact-flutter-3.44' || 'bridge-artifact' }}
path: ./
- name: Install LLVM and Clang
uses: KyleMayes/install-llvm-action@1a3da29f56261a1e1f937ec88f0856a9b8321d7e # v1
uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2.0.9
with:
version: ${{ env.LLVM_VERSION }}
- name: Install flutter
id: flutter
# arm64 builds with FLUTTER_WINDOWS_ARM_VERSION (>=3.44); x64 stays on FLUTTER_VERSION.
# subosito only ships an x64 Windows SDK (Flutter's release manifest lists x64 only),
# so it installs x64 on both arches. The arm64 runner re-bootstraps Dart to arm64 in
# the next step.
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
flutter-version: ${{ matrix.job.arch == 'aarch64' && env.FLUTTER_WINDOWS_ARM_VERSION || env.FLUTTER_VERSION }}
architecture: x64
- name: Force arm64 Dart SDK + engine
# The x64 SDK subosito installs bundles an x64 Dart with a matching engine-dart-sdk.stamp,
# so update_dart_sdk.ps1 short-circuits (stamp matches -> return) and keeps x64 Dart;
# `flutter build windows` then targets the Dart VM's arch = x64, even on this arm64 host.
# On this native-arm64 runner (PROCESSOR_ARCHITECTURE=ARM64), deleting the stamp and
# re-running update_dart_sdk.ps1 pulls the arm64 Dart (available since Flutter 3.44.0).
# https://github.com/flutter/flutter/issues/186730#issuecomment-4573214964
if: ${{ matrix.job.arch == 'aarch64' }}
run: |
$flutterRoot = "${{ steps.flutter.outputs['CACHE-PATH'] }}"
Write-Host "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE"
Write-Host "Flutter root: $flutterRoot"
Remove-Item -Force "$flutterRoot\bin\cache\engine-dart-sdk.stamp" -ErrorAction SilentlyContinue
& "$flutterRoot\bin\internal\update_dart_sdk.ps1"
# Confirm the Dart we ended up with is arm64 ("on windows_arm64"); fail loudly if not.
$dartVer = & "$flutterRoot\bin\dart.bat" --version 2>&1 | Out-String
Write-Host $dartVer
if ($dartVer -notmatch "windows_arm64") {
Write-Error "Expected an arm64 Dart SDK but got: $dartVer"
exit 1
}
& "$flutterRoot\bin\flutter.bat" precache --windows
# Fail fast if precache pulled the wrong-arch Windows engine: an arm64 Dart should
# fetch windows-arm64 engine artifacts. Bailing here saves the ~25min Rust build.
$engineDir = "$flutterRoot\bin\cache\artifacts\engine"
Write-Host "Engine artifacts present:"
Get-ChildItem $engineDir -Directory | Select-Object -ExpandProperty Name | Write-Host
if (-not (Test-Path "$engineDir\windows-arm64-release")) {
Write-Error "Expected windows-arm64-release engine artifacts but they are missing (wrong-arch SDK)."
exit 1
}
# https://github.com/flutter/flutter/issues/155685
# x64 only: arm64 uses the stock native arm64 Windows engine, and the rustdesk/engine
# windows-x64-release.zip is built for the 3.24-era x64 engine (matches FLUTTER_VERSION).
- name: Replace engine with rustdesk custom flutter engine
if: ${{ matrix.job.arch == 'x86_64' }}
run: |
flutter doctor -v
flutter precache --windows
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
mv -Force windows-x64-release/*  C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
- name: Patch flutter
# x64 stays on Flutter 3.24.5, which needs the dropdown filter patch.
# arm64 is on Flutter 3.44 (patched separately below) and does not use this patch.
if: ${{ matrix.job.arch == 'x86_64' }}
shell: bash
run: |
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(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
- 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
# (shared with the 3.44 bridge job) so the committed sources stay on Flutter 3.24.5.
# `flutter build` then runs `pub get`, regenerating pubspec.lock for the bumped deps.
if: ${{ matrix.job.arch == 'aarch64' }}
shell: bash
run: bash .github/patches/apply_flutter_3.44_source_patches.sh
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
@@ -163,11 +244,19 @@ jobs:
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
shell: bash
- name: Set SODIUM_LIB_DIR (arm64)
# libsodium-sys ships no arm64 Windows prebuilt lib; point it at the vcpkg-built one
# (only for arm64 — leaving it unset lets x64 use the crate's bundled lib).
if: ${{ matrix.job.arch == 'aarch64' }}
shell: bash
run: echo "SODIUM_LIB_DIR=$VCPKG_ROOT/installed/${{ matrix.job.vcpkg-triplet }}/lib" >> "$GITHUB_ENV"
- name: Build rustdesk
run: |
# Windows: build RustDesk
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
# --hwcodec is shared by all Windows targets; per-target extras (e.g. --vram) come from the matrix
python3 .\build.py --portable --flutter --skip-portable-pack --hwcodec ${{ matrix.job.build-args }}
mv ./flutter/build/windows/${{ matrix.job.flutter-arch }}/runner/Release ./rustdesk
# Download usbmmidd_v2.zip and extract it to ./rustdesk
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
@@ -223,7 +312,7 @@ jobs:
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: ${{ inputs.upload-artifact }}
with:
name: topmostwindow-artifacts
name: ${{ matrix.job.arch == 'aarch64' && 'topmostwindow-artifacts-ARM64' || 'topmostwindow-artifacts-x64' }}
path: "./rustdesk"
- name: Upload unsigned
@@ -256,13 +345,18 @@ jobs:
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
- name: Build msi
# Builds the MSI for the matrix arch. res/msi (WiX v4 + native CustomActions) carries
# both x64 and ARM64 platform configs; WcaUtil/DUtil ship arm64 libs. msbuild platform
# is x64 / ARM64; the produced Package.msi is globbed since its bin/<platform>/ dir varies.
if: env.UPLOAD_ARTIFACT == 'true'
run: |
pushd ./res/msi
python preprocess.py --arp -d ../../rustdesk
nuget restore msi.sln
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
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
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
sha256sum ../../SignOutput/rustdesk-*.msi
- name: Sign rustdesk self-extracted file
@@ -1890,6 +1984,7 @@ jobs:
sudo apt-get install -y libarchive-tools libfuse2
# set-up appimage-builder
# https://github.com/AppImage/AppImageKit/issues/1395
sudo pip3 install "setuptools_scm<10"
sudo pip3 install git+https://github.com/rustdesk-org/appimage-builder.git
# run appimage-builder
pushd appimage

View File

@@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.7"
VERSION: "1.4.9"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

View File

@@ -45,16 +45,15 @@ jobs:
run: |
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
- name: Build the project
run: |
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
- name: Archive build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ inputs.upload-artifact }}
with:
name: topmostwindow-artifacts
name: topmostwindow-artifacts-${{ inputs.platform }}
path: |
./${{ env.build_output_dir }}/WindowInjection.dll

View File

@@ -1,85 +0,0 @@
name: wf-cliprdr CI
on:
workflow_dispatch:
pull_request:
paths:
- "libs/clipboard/src/windows/**"
- "tests/test_invariant_wf_cliprdr.c"
- ".github/workflows/wf-cliprdr-ci.yml"
push:
branches:
- master
paths:
- "libs/clipboard/src/windows/**"
- "tests/test_invariant_wf_cliprdr.c"
- ".github/workflows/wf-cliprdr-ci.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: wf_cliprdr invariant test
runs-on: windows-2022
steps:
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Set up MSVC
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
with:
arch: x64
- name: Setup vcpkg with GitHub Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: C:\vcpkg
doNotCache: false
- name: Install vcpkg dependency
shell: pwsh
run: |
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
- name: Build test
shell: pwsh
run: |
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
New-Item -ItemType Directory -Force $testRoot | Out-Null
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
$cmakeLists = @(
'cmake_minimum_required(VERSION 3.20)'
'project(test_invariant_wf_cliprdr C)'
''
'set(CMAKE_C_STANDARD 11)'
'set(CMAKE_C_STANDARD_REQUIRED ON)'
'set(CMAKE_C_EXTENSIONS OFF)'
''
'find_package(check CONFIG REQUIRED)'
''
'add_executable(test_invariant_wf_cliprdr'
' "TEST_SOURCE"'
')'
''
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
' $<$<TARGET_EXISTS:Check::check>:Check::check>'
' $<$<NOT:$<TARGET_EXISTS:Check::check>>:Check::checkShared>'
')'
) -join [Environment]::NewLine
$cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt')
cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows
cmake --build (Join-Path $testRoot 'out') --config Release
- name: Run test
shell: pwsh
run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe

82
Cargo.lock generated
View File

@@ -292,7 +292,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "arboard"
version = "3.4.0"
source = "git+https://github.com/rustdesk-org/arboard#85be1218668ff218a7b170c9d424fde73e069914"
source = "git+https://github.com/rustdesk-org/arboard#c7d5781f563176df9efd8df6287e823fb1b9bed5"
dependencies = [
"clipboard-win",
"core-graphics 0.23.2",
@@ -771,6 +771,26 @@ dependencies = [
"syn 2.0.98",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.9.1",
"cexpr",
"clang-sys",
"itertools 0.12.1",
"log",
"prettyplease",
"proc-macro2 1.0.93",
"quote 1.0.36",
"regex",
"rustc-hash 2.1.1",
"shlex",
"syn 2.0.98",
]
[[package]]
name = "bit_field"
version = "0.10.2"
@@ -1324,7 +1344,7 @@ dependencies = [
[[package]]
name = "clipboard-master"
version = "4.0.0-beta.6"
source = "git+https://github.com/rustdesk-org/clipboard-master#ddc39f00a6211959489ae683aa6ae6eedf03a809"
source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db"
dependencies = [
"objc",
"objc-foundation",
@@ -2694,7 +2714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3952,7 +3972,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hwcodec"
version = "0.7.1"
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738"
source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
dependencies = [
"bindgen 0.59.2",
"cc",
@@ -4695,7 +4715,7 @@ dependencies = [
[[package]]
name = "magnum-opus"
version = "0.4.0"
source = "git+https://github.com/rustdesk-org/magnum-opus#5cd2bf989c148662fa3a2d9d539a71d71fd1d256"
source = "git+https://github.com/rustdesk-org/magnum-opus#588c6e1f9ed50c3a01fa64f3bd3e7cdb0378a114"
dependencies = [
"bindgen 0.59.2",
"pkg-config",
@@ -6588,7 +6608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556af5f5c953a2ee13f45753e581a38f9778e6551bc3ccc56d90b14628fe59d8"
dependencies = [
"cfg-if 0.1.10",
"rpassword 2.1.0",
"rpassword",
"tempfile",
"termios 0.3.3",
"winapi 0.3.9",
@@ -6673,7 +6693,7 @@ dependencies = [
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -6920,7 +6940,7 @@ dependencies = [
[[package]]
name = "rdev"
version = "0.5.0-2"
source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8"
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
dependencies = [
"cocoa 0.24.1",
"core-foundation 0.9.4",
@@ -7152,17 +7172,6 @@ dependencies = [
"winapi 0.2.8",
]
[[package]]
name = "rpassword"
version = "7.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.48.0",
]
[[package]]
name = "rtcp"
version = "0.14.0"
@@ -7174,16 +7183,6 @@ dependencies = [
"webrtc-util",
]
[[package]]
name = "rtoolbox"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e"
dependencies = [
"libc",
"windows-sys 0.48.0",
]
[[package]]
name = "rtp"
version = "0.14.0"
@@ -7270,7 +7269,7 @@ dependencies = [
[[package]]
name = "rustdesk"
version = "1.4.7"
version = "1.4.9"
dependencies = [
"android-wakelock",
"android_logger",
@@ -7283,7 +7282,6 @@ dependencies = [
"cfg-if 1.0.0",
"chrono",
"cidr-utils",
"clap 4.5.53",
"clipboard",
"clipboard-master",
"cocoa 0.24.1",
@@ -7341,7 +7339,6 @@ dependencies = [
"repng",
"reqwest",
"ringbuf",
"rpassword 7.3.1",
"rubato",
"runas",
"rust-pulsectl",
@@ -7385,7 +7382,7 @@ dependencies = [
[[package]]
name = "rustdesk-portable-packer"
version = "1.4.7"
version = "1.4.9"
dependencies = [
"brotli",
"dirs 5.0.1",
@@ -7457,7 +7454,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7514,7 +7511,7 @@ dependencies = [
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7601,7 +7598,7 @@ name = "scrap"
version = "0.5.0"
dependencies = [
"android_logger",
"bindgen 0.65.1",
"bindgen 0.72.1",
"block",
"cfg-if 1.0.0",
"dbus",
@@ -9733,9 +9730,9 @@ dependencies = [
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.3"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953"
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
dependencies = [
"bitflags 2.9.1",
"wayland-backend",
@@ -10838,16 +10835,15 @@ dependencies = [
[[package]]
name = "wl-clipboard-rs"
version = "0.9.0"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 0.38.34",
"tempfile",
"thiserror 1.0.61",
"rustix 1.1.2",
"thiserror 2.0.17",
"tree_magic_mini",
"wayland-backend",
"wayland-client",

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk"
version = "1.4.7"
version = "1.4.9"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"
@@ -22,7 +22,6 @@ path = "src/service.rs"
[features]
inline = []
cli = []
use_samplerate = ["samplerate"]
use_rubato = ["rubato"]
use_dasp = ["dasp"]
@@ -62,8 +61,6 @@ dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpol
rubato = { version = "0.12", optional = true }
samplerate = { version = "0.2", optional = true }
uuid = { version = "1.3", features = ["v4"] }
clap = "4.2"
rpassword = "7.2"
num_cpus = "1.15"
bytes = { version = "1.4", features = ["serde"] }
default-net = "0.14"
@@ -213,7 +210,7 @@ exclude = ["vdi/host", "examples/custom_plugin"]
libxdo-sys = { path = "libs/libxdo-sys-stub" }
[package.metadata.winres]
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
ProductName = "RustDesk"
FileDescription = "RustDesk Remote Desktop"
OriginalFilename = "rustdesk.exe"

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.7
version: 1.4.9
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.7
version: 1.4.9
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -17,7 +17,8 @@ osx = platform.platform().startswith(
hbb_name = 'rustdesk' + ('.exe' if windows else '')
exe_path = 'target/release/' + hbb_name
if windows:
flutter_build_dir = 'build/windows/x64/runner/Release/'
win_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64'
flutter_build_dir = f'build/windows/{win_arch}/runner/Release/'
elif osx:
flutter_build_dir = 'build/macos/Build/Products/Release/'
else:
@@ -410,7 +411,12 @@ def build_flutter_dmg(version, features):
system2(
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
os.chdir('flutter')
system2('flutter build macos --release')
# cargo builds a single-arch dylib for the host; restrict Xcode to the same arch
# so the universal-by-default ARCHS_STANDARD doesn't try to link a missing slice.
# FLUTTER_XCODE_* env vars are forwarded to xcodebuild as build settings.
mac_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x86_64'
system2(
f'FLUTTER_XCODE_ARCHS={mac_arch} FLUTTER_XCODE_ONLY_ACTIVE_ARCH=YES flutter build macos --release')
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
'''
system2(
@@ -506,6 +512,7 @@ def main():
'target\\release\\rustdesk.exe')
else:
print('Not signed')
os.makedirs(res_dir, exist_ok=True)
system2(
f'cp -rf target/release/RustDesk.exe {res_dir}')
os.chdir('libs/portable')

View File

@@ -8,6 +8,7 @@ package com.carriez.flutter_hbb
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.content.Intent
import android.graphics.Path
import android.os.Build
import android.os.Bundle
@@ -68,6 +69,16 @@ class InputService : AccessibilityService() {
get() = ctx != null
}
private fun notifyInputState() {
val inputState = isOpen.toString()
Handler(Looper.getMainLooper()).post {
MainActivity.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "input", "value" to inputState)
)
}
}
private val logTag = "input service"
private var leftIsDown = false
private var touchPath = Path()
@@ -716,6 +727,7 @@ class InputService : AccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()
ctx = this
notifyInputState()
val info = AccessibilityServiceInfo()
if (Build.VERSION.SDK_INT >= 33) {
info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS
@@ -734,8 +746,16 @@ class InputService : AccessibilityService() {
override fun onDestroy() {
ctx = null
// Keep this fallback even though onUnbind usually notifies first.
notifyInputState()
super.onDestroy()
}
override fun onUnbind(intent: Intent?): Boolean {
ctx = null
notifyInputState()
return super.onUnbind(intent)
}
override fun onInterrupt() {}
}

View File

@@ -200,12 +200,13 @@ class MainActivity : FlutterActivity() {
"stop_input" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
InputService.ctx?.disableSelf()
} else {
InputService.ctx = null
Companion.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "input", "value" to InputService.isOpen.toString())
)
}
InputService.ctx = null
Companion.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "input", "value" to InputService.isOpen.toString())
)
result.success(true)
}
"cancel_notification" -> {

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199"><path fill="#0089d6" d="M118.432 187.698c32.89-5.81 60.055-10.618 60.367-10.684l.568-.12-31.052-36.935c-17.078-20.314-31.051-37.014-31.051-37.11 0-.182 32.063-88.477 32.243-88.792.06-.105 21.88 37.567 52.893 91.32 29.035 50.323 52.973 91.815 53.195 92.203l.405.707-98.684-.012-98.684-.013 59.8-10.564zM0 176.435c0-.052 14.631-25.451 32.514-56.442l32.514-56.347 37.891-31.799C123.76 14.358 140.867.027 140.935.001c.069-.026-.205.664-.609 1.534s-18.919 40.582-41.145 88.25l-40.41 86.67-29.386.037c-16.162.02-29.385-.005-29.385-.057z"/></svg>

Before

Width:  |  Height:  |  Size: 604 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<g fill="#000000" fill-rule="evenodd">
<rect x="4" y="6" width="24" height="16" rx="3"/>
<rect x="14.5" y="22" width="3" height="2"/>
<rect x="9.5" y="24" width="13" height="2.5" rx="1.25"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@@ -6,83 +6,82 @@ ANDROID_ABI=$1
# Build RustDesk dependencies for Android using vcpkg.json
# Required:
# 1. set VCPKG_ROOT / ANDROID_NDK path environment variables
# 1. set VCPKG_ROOT / ANDROID_NDK_HOME path environment variables
# 2. vcpkg initialized
# 3. ndk, version: r25c or newer
if [ -z "$ANDROID_NDK_HOME" ]; then
echo "Failed! Please set ANDROID_NDK_HOME"
exit 1
if [ -z "${ANDROID_NDK_HOME}" ]; then
echo "ERROR: Please set ANDROID_NDK_HOME environment variable" 1>&2
exit 1
fi
if [ -z "$VCPKG_ROOT" ]; then
echo "Failed! Please set VCPKG_ROOT"
exit 1
if [ -z "${VCPKG_ROOT}" ]; then
echo "ERROR: Please set VCPKG_ROOT environment variable" 1>&2
exit 1
fi
API_LEVEL="21"
case "${ANDROID_ABI}" in
arm64-v8a)
VCPKG_TARGET=arm64-android
;;
armeabi-v7a)
VCPKG_TARGET=arm-neon-android
;;
x86_64)
VCPKG_TARGET=x64-android
;;
x86)
VCPKG_TARGET=x86-android
;;
*)
echo "Usage: build_android_deps.sh <arm64-v8a|armeabi-v7a|x86_64|x86>" 1>&2
exit 1
;;
esac
# Get directory of this script
SCRIPTDIR="$(readlink -f "$0")"
SCRIPTDIR="$(dirname "$SCRIPTDIR")"
SCRIPTDIR="$(dirname "${SCRIPTDIR}")"
# Check if vcpkg.json is one level up - in root directory of RD
if [ ! -f "$SCRIPTDIR/../vcpkg.json" ]; then
echo "Failed! Please check where vcpkg.json is!"
exit 1
if [ ! -f "${SCRIPTDIR}/../vcpkg.json" ]; then
echo "ERROR: Can not find vcpkg.json in RustDesk top-level directory" 1>&2
exit 1
fi
# NDK llvm toolchain
echo "INFO: Building and install vcpkg dependencies for Android ${ANDROID_ABI} ..."
HOST_TAG="linux-x86_64" # current platform, set as `ls $ANDROID_NDK/toolchains/llvm/prebuilt/`
TOOLCHAIN=$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG
pushd "${SCRIPTDIR}/.."
function build {
ANDROID_ABI=$1
"${VCPKG_ROOT}/vcpkg" install \
--triplet "${VCPKG_TARGET}" \
--x-install-root="${VCPKG_ROOT}/installed"
case "$ANDROID_ABI" in
arm64-v8a)
ABI=aarch64-linux-android$API_LEVEL
VCPKG_TARGET=arm64-android
;;
armeabi-v7a)
ABI=armv7a-linux-androideabi$API_LEVEL
VCPKG_TARGET=arm-neon-android
;;
x86_64)
ABI=x86_64-linux-android$API_LEVEL
VCPKG_TARGET=x64-android
;;
x86)
ABI=i686-linux-android$API_LEVEL
VCPKG_TARGET=x86-android
;;
*)
echo "ERROR: ANDROID_ABI must be one of: arm64-v8a, armeabi-v7a, x86_64, x86" >&2
return 1
esac
popd
echo "*** [$ANDROID_ABI][Start] Build and install vcpkg dependencies"
pushd "$SCRIPTDIR/.."
$VCPKG_ROOT/vcpkg install --triplet $VCPKG_TARGET --x-install-root="$VCPKG_ROOT/installed"
popd
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-$VCPKG_TARGET-rel-out.log" || true
echo "*** [$ANDROID_ABI][Finished] Build and install vcpkg dependencies"
echo "INFO: Completed building vcpkg dependencies for Android ${ANDROID_ABI}"
if [ -d "$VCPKG_ROOT/installed/arm-neon-android" ]; then
echo "*** [Start] Move arm-neon-android to arm-android"
if [ "${ANDROID_ABI}" = 'armeabi-v7a' ]; then
# Symlink arm-neon-android to arm-android because cargo-ndk does not
# understand NEON suffix.
mv "$VCPKG_ROOT/installed/arm-neon-android" "$VCPKG_ROOT/installed/arm-android"
if [ -d "${VCPKG_ROOT}/installed/arm-neon-android" ]; then
echo 'INFO: Symlinking arm-neon-android to arm-android'
echo "*** [Finished] Move arm-neon-android to arm-android"
fi
}
if [ ! -z "$ANDROID_ABI" ]; then
build "$ANDROID_ABI"
else
echo "Usage: build-android-deps.sh <ANDROID-ABI>" >&2
exit 1
ln -sf \
"${VCPKG_ROOT}/installed/arm-neon-android" \
"${VCPKG_ROOT}/installed/arm-android"
echo 'INFO: Symlinked arm-neon-android to arm-android'
else
cat 0<<.a
ERROR: 'vcpkg install' seem to complete successfully but
directory '${VCPKG_ROOT}/installed/arm-neon-android' is missing!
.a
exit 1
fi
fi

View File

@@ -1185,6 +1185,48 @@ void msgBox(SessionID sessionId, String type, String title, String text,
VoidCallback? onSubmit,
int? submitTimeout}) {
dialogManager.dismissAll();
if (type.contains('insecure-connection')) {
Future<void> closeSession() async {
await bind.sessionSetCommon(
sessionId: sessionId,
key: 'continue-insecure-connection',
value: 'N',
);
dialogManager.dismissAll();
closeConnection();
}
void continueSession() {
unawaited(
bind.sessionSetCommon(
sessionId: sessionId,
key: 'continue-insecure-connection',
value: 'Y',
),
);
dialogManager.dismissAll();
}
dialogManager.show(
(setState, close, context) => CustomAlertDialog(
title: null,
content: SelectionArea(child: msgboxContent(type, title, text)),
actions: [
dialogButton(
'Continue',
onPressed: continueSession,
isOutline: true,
),
dialogButton('Disconnect', onPressed: closeSession),
],
onSubmit: closeSession,
onCancel: closeSession,
),
tag: '$sessionId-$type-$title-$text-$link',
);
return;
}
List<Widget> buttons = [];
bool hasOk = false;
submit() {
@@ -3350,7 +3392,12 @@ Future<List<Rect>> getScreenRectList() async {
}
openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
{bool updateCursorPos = true}) {
{bool updateCursorPos = true, bool recordSelection = true}) {
if (recordSelection) {
ffi.ffiModel.lastUserDisplay = i;
ffi.ffiModel.cancelPendingRestoreTimer();
ffi.ffiModel.pendingMonitorRestore = null;
}
final displays = i == kAllDisplayValue
? List.generate(pi.displays.length, (index) => index)
: [i];
@@ -3713,14 +3760,54 @@ Widget loadPowered(BuildContext context) {
).marginOnly(top: 6);
}
// max 300 x 60
Widget loadLogo() {
return FutureBuilder<ByteData>(
future: rootBundle.load('assets/logo.png'),
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
if (snapshot.hasData) {
const _kDefaultLogoAsset = 'assets/logo.png';
const _kLightLogoAsset = 'assets/logo_light.png';
const _kDarkLogoAsset = 'assets/logo_dark.png';
List<String> _logoAssetCandidatesForBrightness(Brightness brightness) {
return brightness == Brightness.dark
? [_kDarkLogoAsset, _kDefaultLogoAsset]
: [_kLightLogoAsset, _kDefaultLogoAsset];
}
Future<String?> _resolveLogoAsset(Brightness brightness) async {
for (final asset in _logoAssetCandidatesForBrightness(brightness)) {
try {
await rootBundle.load(asset);
return asset;
} on FlutterError {
continue;
}
}
return null;
}
class _Logo extends StatefulWidget {
const _Logo();
@override
State<_Logo> createState() => _LogoState();
}
class _LogoState extends State<_Logo> {
final Map<Brightness, Future<String?>> _logoFutures = {};
Future<String?> _logoFutureFor(Brightness brightness) {
return _logoFutures.putIfAbsent(
brightness,
() => _resolveLogoAsset(brightness),
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String?>(
future: _logoFutureFor(Theme.of(context).brightness),
builder: (BuildContext context, AsyncSnapshot<String?> snapshot) {
final asset = snapshot.data;
if (asset != null) {
final image = Image.asset(
'assets/logo.png',
asset,
fit: BoxFit.contain,
errorBuilder: (ctx, error, stackTrace) {
return Container();
@@ -3732,9 +3819,14 @@ Widget loadLogo() {
).marginOnly(left: 12, right: 12, top: 12);
}
return const Offstage();
});
},
);
}
}
// max 300 x 60
Widget loadLogo() => const _Logo();
Widget loadIcon(double size) {
return Image.asset('assets/icon.png',
width: size,

View File

@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import '../../../models/platform_model.dart';
@@ -5,27 +8,136 @@ import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/peer_card.dart';
@visibleForTesting
List<Peer> mergeAutocompletePeers({
Iterable<Peer> addressBookPeers = const [],
Iterable<Peer> groupPeers = const [],
Iterable<Peer> lanPeers = const [],
Iterable<Peer> recentPeers = const [],
Iterable<String> restRecentPeerIds = const [],
}) {
final combinedPeers = <String, Peer>{};
void addPeer(Peer peer) {
if (peer.id.isEmpty) {
return;
}
final existingPeer = combinedPeers[peer.id];
if (existingPeer == null) {
combinedPeers[peer.id] = Peer.copy(peer);
} else if (peer.online) {
existingPeer.online = true;
}
}
for (final peer in addressBookPeers) {
addPeer(peer);
}
for (final peer in groupPeers) {
addPeer(peer);
}
for (final peer in lanPeers) {
addPeer(peer);
}
for (final peer in recentPeers) {
addPeer(peer);
}
for (final id in restRecentPeerIds) {
if (id.isNotEmpty && !combinedPeers.containsKey(id)) {
combinedPeers[id] = Peer.fromJson({'id': id});
}
}
return combinedPeers.values.toList(growable: false);
}
@visibleForTesting
bool updateAutocompletePeerOnlineStates(
List<Peer> peers, {
required Set<String> onlines,
required Set<String> offlines,
}) {
var changed = false;
for (final peer in peers) {
if (onlines.contains(peer.id)) {
if (!peer.online) {
peer.online = true;
changed = true;
}
} else if (offlines.contains(peer.id)) {
if (peer.online) {
peer.online = false;
changed = true;
}
}
}
return changed;
}
@visibleForTesting
List<String> autocompleteOnlineQueryIds(
Iterable<Peer> options, {
required int limit,
}) {
final ids = <String>[];
final seenIds = <String>{};
for (final peer in options) {
if (peer.id.isEmpty || seenIds.contains(peer.id)) {
continue;
}
seenIds.add(peer.id);
ids.add(peer.id);
if (ids.length >= limit) {
break;
}
}
return ids;
}
class AllPeersLoader {
List<Peer> peers = [];
bool _isPeersLoading = false;
bool _isPeersLoaded = false;
Set<String> _lastQueryOnlineIds = {};
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
Timer? _queryOnlineTimer;
List<Peer> _lastQueryOnlineOptions = const [];
Set<String> _lastOnlineIds = {};
Set<String> _lastOfflineIds = {};
final Future<void> Function(List<String> ids) _queryOnlines;
final Duration _queryOnlineDebounce;
void Function(VoidCallback)? _setState;
bool _isCleared = false;
final String _listenerKey = 'AllPeersLoader';
late void Function(VoidCallback) setState;
static const String _cbQueryOnlines = 'callback_query_onlines';
static const Duration _queryOnlineInterval = Duration(seconds: 5);
static const Duration _defaultQueryOnlineDebounce =
Duration(milliseconds: 300);
static const int _maxQueryOnlineOptions = 20;
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
bool get isPeersLoaded => _isPeersLoaded;
AllPeersLoader();
AllPeersLoader({
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
@visibleForTesting Duration? queryOnlineDebounce,
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
_queryOnlineDebounce =
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
void init(void Function(VoidCallback) setState) {
this.setState = setState;
_setState = setState;
_isCleared = false;
gFFI.recentPeersModel.addListener(_mergeAllPeers);
gFFI.lanPeersModel.addListener(_mergeAllPeers);
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
(evt) async {
_updateOnlineState(evt);
});
}
void clear() {
@@ -33,6 +145,11 @@ class AllPeersLoader {
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
gFFI.abModel.removePeerUpdateListener(_listenerKey);
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
_queryOnlineTimer?.cancel();
_lastQueryOnlineOptions = const [];
_setState = null;
_isCleared = true;
}
Future<void> getAllPeers() async {
@@ -59,50 +176,106 @@ class AllPeersLoader {
}
void _mergeAllPeers() {
Map<String, dynamic> combinedPeers = {};
for (var p in gFFI.abModel.allPeers()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
if (_isCleared) {
return;
}
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
}
List<Peer> parsedPeers = [];
for (var peer in combinedPeers.values) {
parsedPeers.add(Peer.fromJson(peer));
}
Set<String> peerIds = combinedPeers.keys.toSet();
for (final peer in gFFI.lanPeersModel.peers) {
if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (final peer in gFFI.recentPeersModel.peers) {
if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (final id in gFFI.recentPeersModel.restPeerIds) {
if (!peerIds.contains(id)) {
parsedPeers.add(Peer.fromJson({'id': id}));
peerIds.add(id);
}
}
peers = parsedPeers;
setState(() {
peers = mergeAutocompletePeers(
addressBookPeers: gFFI.abModel.allPeers(),
groupPeers: gFFI.groupModel.peers,
lanPeers: gFFI.lanPeersModel.peers,
recentPeers: gFFI.recentPeersModel.peers,
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
);
_applyLastOnlineState(peers);
_scheduleSetState(() {
_isPeersLoading = false;
_isPeersLoaded = true;
});
}
void _updateOnlineState(Map<String, dynamic> evt) {
if (_isCleared) {
return;
}
_lastOnlineIds = _splitPeerIds(evt['onlines']);
_lastOfflineIds = _splitPeerIds(evt['offlines']);
final peersChanged = _applyLastOnlineState(peers);
final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions);
if (peersChanged || optionsChanged) {
_scheduleSetState(() {});
}
}
void _scheduleSetState(VoidCallback callback) {
if (_isCleared) {
return;
}
final setState = _setState;
if (setState == null) {
callback();
} else {
setState(callback);
}
}
bool _applyLastOnlineState(List<Peer> peers) {
return updateAutocompletePeerOnlineStates(
peers,
onlines: _lastOnlineIds,
offlines: _lastOfflineIds,
);
}
Set<String> _splitPeerIds(dynamic ids) {
if (ids is! String || ids.isEmpty) {
return {};
}
return ids.split(',').where((id) => id.isNotEmpty).toSet();
}
void queryOnlines(Iterable<Peer> options) {
if (_isCleared) {
return;
}
_lastQueryOnlineOptions = options.toList(growable: false);
final ids = autocompleteOnlineQueryIds(
_lastQueryOnlineOptions,
limit: _maxQueryOnlineOptions,
).toSet();
_queryOnlineTimer?.cancel();
_queryOnlineTimer = null;
if (ids.isEmpty) {
return;
}
final now = DateTime.now();
if (setEquals(ids, _lastQueryOnlineIds) &&
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
return;
}
_queryOnlineTimer = Timer(_queryOnlineDebounce, () async {
try {
await _queryOnlines(ids.toList(growable: false));
if (_isCleared) {
return;
}
_lastQueryOnlineIds = ids;
_lastQueryOnlineTime = DateTime.now();
} catch (e) {
debugPrint('query autocomplete online state failed: $e');
}
});
}
@visibleForTesting
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
_updateOnlineState(evt);
}
@visibleForTesting
bool applyLastOnlineStateForTesting(List<Peer> peers) {
return _applyLastOnlineState(peers);
}
}
class AutocompletePeerTile extends StatefulWidget {

View File

@@ -24,6 +24,35 @@ const kOpSvgList = [
'microsoft'
];
class _OidcProviderBranding {
final String label;
final String iconKey;
const _OidcProviderBranding({
required this.label,
required this.iconKey,
});
}
_OidcProviderBranding _oidcProviderBranding(String op) {
switch (op.toLowerCase()) {
case 'azure':
return _OidcProviderBranding(
label: 'Microsoft',
iconKey: 'microsoft',
);
default:
return _OidcProviderBranding(
label: {
'github': 'GitHub',
'gitlab': 'GitLab',
}[op.toLowerCase()] ??
toCapitalized(op),
iconKey: op.toLowerCase(),
);
}
}
class _IconOP extends StatelessWidget {
final String op;
final String? icon;
@@ -74,11 +103,8 @@ class ButtonOP extends StatelessWidget {
@override
Widget build(BuildContext context) {
final opLabel = {
'github': 'GitHub',
'gitlab': 'GitLab'
}[op.toLowerCase()] ??
toCapitalized(op);
final branding = _oidcProviderBranding(op);
final buttonLabel = translate("Continue with {${branding.label}}");
return Row(children: [
Container(
height: height,
@@ -95,7 +121,7 @@ class ButtonOP extends StatelessWidget {
SizedBox(
width: 30,
child: _IconOP(
op: op,
op: branding.iconKey,
icon: icon,
margin: EdgeInsets.only(right: 5),
),
@@ -103,8 +129,7 @@ class ButtonOP extends StatelessWidget {
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Center(
child: Text(translate("Continue with {$opLabel}"))),
child: Center(child: Text(buttonLabel)),
),
),
],

View File

@@ -532,9 +532,7 @@ class _RawTouchGestureDetectorRegionState
// Official
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
() => TapGestureRecognizer(), (instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
@@ -542,18 +540,14 @@ class _RawTouchGestureDetectorRegionState
}),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
() => DoubleTapGestureRecognizer(), (instance) {
instance
..onDoubleTapDown = onDoubleTapDown
..onDoubleTap = onDoubleTap;
}),
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
() => LongPressGestureRecognizer(), (instance) {
instance
..onLongPressDown = onLongPressDown
..onLongPressUp = onLongPressUp
@@ -563,9 +557,7 @@ class _RawTouchGestureDetectorRegionState
// Customized
HoldTapMoveGestureRecognizer:
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
() => HoldTapMoveGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
),
() => HoldTapMoveGestureRecognizer(),
(instance) => instance
..onHoldDragStart = onHoldDragStart
..onHoldDragUpdate = onHoldDragUpdate
@@ -573,18 +565,14 @@ class _RawTouchGestureDetectorRegionState
..onHoldDragEnd = onHoldDragEnd),
DoubleFinerTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
() => DoubleFinerTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
() => DoubleFinerTapGestureRecognizer(), (instance) {
instance
..onDoubleFinerTap = onDoubleFinerTap
..onDoubleFinerTapDown = onDoubleFinerTapDown;
}),
CustomTouchGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
() => CustomTouchGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
() => CustomTouchGestureRecognizer(), (instance) {
instance.onOneFingerPanStart =
(DragStartDetails d) => onOneFingerPanStart(context, d);
instance

View File

@@ -72,10 +72,24 @@ Widget waylandKeyboardScopeChip(BuildContext context, String text) {
);
}
// macOS privacy mode blacks out all online displays, so switching the remote
// display does not weaken the local privacy protection.
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) {
return pi.platform == kPeerPlatformMacOS;
bool _isWindowsMode1PrivacyImpl(String privacyModeImpl) {
return privacyModeImpl == kPrivacyModeImplMag ||
privacyModeImpl == kPrivacyModeImplExcludeFromCapture;
}
// macOS privacy mode blacks out all online displays. Windows Mode 1 also
// covers every local monitor with privacy overlay windows, so remote display
// switching does not weaken local privacy protection.
//
// Keep this separate from the capture backend capability. The legacy Windows
// magnifier capturer is not reliable for multi-monitor capture; WebRTC's
// screen_capturer_win_magnifier also disables it when SM_CMONITORS != 1:
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) {
return pi.platform == kPeerPlatformMacOS ||
(pi.platform == kPeerPlatformWindows &&
_isWindowsMode1PrivacyImpl(privacyModeImpl) &&
versionCmp(pi.version, '1.4.8') >= 0);
}
class TTextMenu {
@@ -592,7 +606,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
// to-do:
// 1. Web desktop
// 2. Mobile, copy the image to the clipboard
if (isDesktop) {
if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) {
final isScreenshotSupported = bind.sessionGetCommonSync(
sessionId: sessionId, key: 'is_screenshot_supported', param: '');
if ('true' == isScreenshotSupported) {
@@ -964,7 +978,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
final privacyModeState = PrivacyModeState.find(id);
if (pi.isSupportMultiDisplay &&
(privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
(privacyModeState.isEmpty ||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
pi.displaysCount.value > 1 &&
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
final value =
@@ -1048,7 +1063,20 @@ List<TToggleMenu> toolbarPrivacyMode(
return []; // No permission and not active, hide options.
}
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) {
if (!turnOn ||
allowDisplaySwitchInPrivacyMode(pi, targetImplKey) ||
(ffiModel.pi.currentDisplay == 0 &&
!bind.sessionIsMultiUiSession(sessionId: sessionId))) {
return true;
}
msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info',
'Please switch to Display 1 first', '', ffi.dialogManager);
return false;
}
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc,
String targetImplKey) {
final enabled = !ffiModel.viewOnly &&
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
return TToggleMenu(
@@ -1056,16 +1084,7 @@ List<TToggleMenu> toolbarPrivacyMode(
onChanged: enabled
? (value) {
if (value == null) return;
if (!allowDisplaySwitchInPrivacyMode(pi) &&
ffiModel.pi.currentDisplay != 0 &&
ffiModel.pi.currentDisplay != kAllDisplayValue) {
msgBox(
sessionId,
'custom-nook-nocancel-hasclose',
'info',
'Please switch to Display 1 first',
'',
ffi.dialogManager);
if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
return;
}
final option = 'privacy-mode';
@@ -1083,7 +1102,7 @@ List<TToggleMenu> toolbarPrivacyMode(
getDefaultMenu((sid, opt) async {
bind.sessionToggleOption(sessionId: sid, value: opt);
togglePrivacyModeTime = DateTime.now();
})
}, kPrivacyModeImplMag)
];
}
if (privacyModeImpls.isEmpty) {
@@ -1097,7 +1116,7 @@ List<TToggleMenu> toolbarPrivacyMode(
bind.sessionTogglePrivacyMode(
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
togglePrivacyModeTime = DateTime.now();
})
}, implKey)
];
} else {
final visibleImpls = hasPrivacyModePermission
@@ -1118,6 +1137,9 @@ List<TToggleMenu> toolbarPrivacyMode(
? (value) {
if (value == null) return;
if (value && !hasPrivacyModePermission) return;
if (!checkDisplayAllowedForPrivacyMode(implKey, value)) {
return;
}
togglePrivacyModeTime = DateTime.now();
bind.sessionTogglePrivacyMode(
sessionId: sessionId, implKey: implKey, on: value);

View File

@@ -29,6 +29,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
const String kPlatformAdditionsSupportedPrivacyModeImpl =
"supported_privacy_mode_impl";
const String kPrivacyModeImplMag = 'privacy_mode_impl_mag';
const String kPrivacyModeImplExcludeFromCapture =
'privacy_mode_impl_exclude_from_capture';
const String kPeerPlatformWindows = "Windows";
const String kPeerPlatformLinux = "Linux";
const String kPeerPlatformMacOS = "Mac OS";
@@ -170,6 +174,8 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
// network options

View File

@@ -398,6 +398,7 @@ class _ConnectionPageState extends State<ConnectionPage>
.contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind))
.toList();
_allPeersLoader.queryOnlines(_autocompleteOpts);
}
return _autocompleteOpts;
},

View File

@@ -407,6 +407,7 @@ class _GeneralState extends State<_General> {
final RxBool serviceStop =
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
RxBool serviceBtnEnabled = true.obs;
final GlobalKey _minToolbarOptionKey = GlobalKey();
@override
Widget build(BuildContext context) {
@@ -482,13 +483,15 @@ class _GeneralState extends State<_General> {
}
Widget other() {
final incomingOnly = bind.isIncomingOnly();
final outgoingOnly = bind.isOutgoingOnly();
final showAutoUpdate = isWindows && bind.mainIsInstalled();
final children = <Widget>[
if (!isWeb && !bind.isIncomingOnly())
if (!isWeb && !incomingOnly)
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
kOptionEnableConfirmClosingTabs,
isServer: false),
if (!bind.isIncomingOnly())
if (!incomingOnly)
_OptionCheckBox(
context,
'allow-remote-toolbar-docking-any-edge',
@@ -498,9 +501,10 @@ class _GeneralState extends State<_General> {
reloadAllWindows();
},
),
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
if (!isWeb && !outgoingOnly)
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
if (!isWeb) wallpaper(),
if (!isWeb && !bind.isIncomingOnly()) ...[
if (!isWeb && !incomingOnly) ...[
_OptionCheckBox(
context,
'Open connection in new tab',
@@ -539,40 +543,40 @@ class _GeneralState extends State<_General> {
isServer: false,
),
),
if (!isWeb && !bind.isCustomClient())
_OptionCheckBox(
context,
'Check for software update on startup',
kOptionEnableCheckUpdate,
isServer: false,
),
if (showAutoUpdate)
_OptionCheckBox(
context,
'Auto update',
kOptionAllowAutoUpdate,
isServer: true,
),
if (isWindows && !bind.isOutgoingOnly())
_OptionCheckBox(
context,
'Capture screen using DirectX',
kOptionDirectxCapture,
),
if (!bind.isIncomingOnly()) ...[
_OptionCheckBox(
context,
'Enable UDP hole punching',
kOptionEnableUdpPunch,
isServer: false,
),
_OptionCheckBox(
context,
'Enable IPv6 P2P connection',
kOptionEnableIpv6Punch,
isServer: false,
),
],
],
if (!isWeb && !bind.isCustomClient())
_OptionCheckBox(
context,
'Check for software update on startup',
kOptionEnableCheckUpdate,
isServer: false,
),
if (showAutoUpdate)
_OptionCheckBox(
context,
'Auto update',
kOptionAllowAutoUpdate,
isServer: true,
),
if (isWindows && !outgoingOnly)
_OptionCheckBox(
context,
'Capture screen using DirectX',
kOptionDirectxCapture,
),
if (!isWeb && !incomingOnly) ...[
_OptionCheckBox(
context,
'Enable UDP hole punching',
kOptionEnableUdpPunch,
isServer: false,
),
_OptionCheckBox(
context,
'Enable IPv6 P2P connection',
kOptionEnableIpv6Punch,
isServer: false,
),
],
];
@@ -605,6 +609,47 @@ class _GeneralState extends State<_General> {
},
));
}
children.add(_OptionCheckBox(
context,
'Show monitor switch button on the main toolbar',
kOptionAllowMonitorSwitchMainToolbar,
isServer: false,
update: (enabled) async {
if (!enabled) {
await mainSetLocalBoolOption(
kOptionAllowMonitorSwitchMinToolbar, false);
}
if (mounted) setState(() {});
reloadAllWindows();
if (enabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _minToolbarOptionKey.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
}
},
));
if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
children.add(KeyedSubtree(
key: _minToolbarOptionKey,
child: _OptionCheckBox(
context,
'Show on the minimized toolbar',
kOptionAllowMonitorSwitchMinToolbar,
isServer: false,
update: (_) {
reloadAllWindows();
},
).marginOnly(left: _kCheckBoxLeftMargin * 3),
));
}
return _Card(title: 'Other', children: children);
}
@@ -2429,7 +2474,7 @@ class _AboutState extends State<_About> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license',
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license',
style: const TextStyle(color: Colors.white),
),
Text(

View File

@@ -65,7 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
late final TextEditingController controller;
final RxBool startmenu = true.obs;
final RxBool desktopicon = true.obs;
final RxBool printer = true.obs;
final RxBool printer = false.obs;
final RxBool showProgress = false.obs;
final RxBool btnEnabled = true.obs;
@@ -80,7 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
final installOptions = jsonDecode(bind.installInstallOptions());
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
printer.value = installOptions['PRINTER'] != '0';
printer.value = installOptions['PRINTER'] == '1';
}
@override

View File

@@ -495,14 +495,14 @@ class _CmHeaderState extends State<_CmHeader>
if (client.type_() == ClientType.file)
FittedBox(
child: Text(
translate("File Transfer"),
translate("Transfer file"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
if (client.type_() == ClientType.camera)
FittedBox(
child: Text(
translate("View Camera"),
translate("View camera"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),

View File

@@ -95,6 +95,13 @@ class _TerminalPageState extends State<TerminalPage>
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Auto-close tab when shell exits
_terminalModel.onClosed = () {
if (mounted) {
widget.tabController.closeBy(widget.tabKey);
}
};
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id);

View File

@@ -779,6 +779,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
borderRadius: borderRadius,
child: _DraggableShowHide(
id: widget.id,
ffi: widget.ffi,
sessionId: widget.ffi.sessionId,
dragging: _dragging,
fraction: _fraction,
@@ -805,13 +806,25 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
final List<Widget> toolbarItems = [];
toolbarItems.add(_PinMenu(state: widget.state));
toolbarItems.add(Obx(() {
final privacyModeState = PrivacyModeState.find(widget.id);
if ((privacyModeState.isEmpty ||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
pi.displaysCount.value > 1 &&
mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
return _MainMonitorSwitchButton(id: widget.id, ffi: widget.ffi);
} else {
return const Offstage();
}
}));
if (!isWebDesktop) {
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
}
toolbarItems.add(Obx(() {
if ((PrivacyModeState.find(widget.id).isEmpty ||
allowDisplaySwitchInPrivacyMode(pi)) &&
final privacyModeState = PrivacyModeState.find(widget.id);
if ((privacyModeState.isEmpty ||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
pi.displaysCount.value > 1) {
return _MonitorMenu(
id: widget.id,
@@ -964,6 +977,88 @@ class _MobileActionMenu extends StatelessWidget {
}
}
class _MonitorCycle {
final String id;
final FFI ffi;
const _MonitorCycle(this.id, this.ffi);
PeerInfo get _pi => ffi.ffiModel.pi;
int get total => _pi.displays.length;
int get _current => CurrentDisplayState.find(id).value;
bool get _inRange => _current >= 0 && _current < total;
String get label => _inRange ? '${_current + 1}' : '*';
String get tooltip => '${translate('Switch display')} ($label/$total)';
void next() {
final t = total;
if (t < 2) return;
final from = _inRange ? _current : -1;
final target = (from + 1) % t;
final isChooseDisplayToOpenInNewWindow = _pi.isSupportMultiDisplay &&
bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) ==
'Y';
if (isChooseDisplayToOpenInNewWindow) {
openMonitorInNewTabOrWindow(target, ffi.id, _pi);
} else {
openMonitorInTheSameTab(target, ffi, _pi, updateCursorPos: false);
}
}
}
class _MainMonitorSwitchButton extends StatelessWidget {
final String id;
final FFI ffi;
const _MainMonitorSwitchButton({
Key? key,
required this.id,
required this.ffi,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final cycle = _MonitorCycle(id, ffi);
return Obx(() {
if (cycle.total < 2) return const Offstage();
final label = cycle.label;
return _IconMenuButton(
tooltip: cycle.tooltip,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
onPressed: cycle.next,
icon: SizedBox(
width: _ToolbarTheme.buttonSize,
height: _ToolbarTheme.buttonSize,
child: Stack(
alignment: const Alignment(0, -0.125),
children: [
SvgPicture.asset(
'assets/display_switcher.svg',
colorFilter:
const ColorFilter.mode(Colors.white, BlendMode.srcIn),
width: _ToolbarTheme.buttonSize,
height: _ToolbarTheme.buttonSize,
),
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.black,
fontSize: 11,
height: 1,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
});
}
}
class _MonitorMenu extends StatelessWidget {
final String id;
final FFI ffi;
@@ -1072,8 +1167,8 @@ class _MonitorMenu extends StatelessWidget {
tooltip: isMulti
? ''
: isAllMonitors
? 'all monitors'
: '#${i + 1} monitor',
? 'All monitors'
: '#{${i + 1}} monitor',
hMargin: isMulti ? null : 6,
vMargin: isMulti ? null : 12,
topLevel: false,
@@ -2757,7 +2852,7 @@ class _IconMenuButtonState extends State<_IconMenuButton> {
horizontal: widget.hMargin ?? _ToolbarTheme.buttonHMargin,
vertical: widget.vMargin ?? _ToolbarTheme.buttonVMargin);
button = Tooltip(
message: widget.tooltip,
message: translate(widget.tooltip),
child: button,
);
if (widget.topLevel) {
@@ -2970,6 +3065,7 @@ class RdoMenuButton<T> extends StatelessWidget {
class _DraggableShowHide extends StatefulWidget {
final String id;
final FFI ffi;
final SessionID sessionId;
final RxDouble fraction;
final Rx<_ToolbarEdge> edge;
@@ -2993,6 +3089,7 @@ class _DraggableShowHide extends StatefulWidget {
const _DraggableShowHide({
Key? key,
required this.id,
required this.ffi,
required this.sessionId,
required this.fraction,
required this.edge,
@@ -3249,6 +3346,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
mainAxisSize: MainAxisSize.min,
children: [
_buildDraggable(context),
Obx(() => collapse.isTrue
? _MinimizedMonitorSwitchButton(id: widget.id, ffi: widget.ffi)
: const Offstage()),
Obx(() => buttonWrapper(
() {
widget.setFullscreen(!isFullscreen.value);
@@ -3409,3 +3509,73 @@ class EdgeThicknessControl extends StatelessWidget {
return slider;
}
}
class _MinimizedMonitorSwitchButton extends StatelessWidget {
final String id;
final FFI ffi;
const _MinimizedMonitorSwitchButton({
Key? key,
required this.id,
required this.ffi,
}) : super(key: key);
@override
Widget build(BuildContext context) {
const double iconSize = 20;
final cycle = _MonitorCycle(id, ffi);
return Obx(() {
final label = cycle.label;
if (!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar) ||
!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMinToolbar)) {
return const Offstage();
}
if (cycle.total < 2) return const Offstage();
final privacyModeState = PrivacyModeState.find(id);
if (privacyModeState.isNotEmpty &&
!allowDisplaySwitchInPrivacyMode(
ffi.ffiModel.pi, privacyModeState.value)) {
return const Offstage();
}
return Tooltip(
message: cycle.tooltip,
child: TextButton(
onPressed: cycle.next,
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(const Size(0, 0)),
padding: MaterialStateProperty.all(EdgeInsets.zero),
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.hovered)) {
return _ToolbarTheme.blueColor.withOpacity(0.15);
}
return null;
}),
),
child: Stack(
alignment: const Alignment(0, -0.125),
children: [
SvgPicture.asset(
'assets/display_switcher.svg',
colorFilter:
ColorFilter.mode(_ToolbarTheme.blueColor, BlendMode.srcIn),
width: iconSize,
height: iconSize,
),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
height: 1,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
});
}
}

View File

@@ -207,6 +207,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
.contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind))
.toList();
_allPeersLoader.queryOnlines(_autocompleteOpts);
}
return _autocompleteOpts;
},

View File

@@ -517,10 +517,12 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
return Container(
color: MyTheme.canvasColor,
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
),
child: inputModel.isPhysicalMouse.value
? getBodyForMobile()
: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
),
);
}),
),
@@ -1218,7 +1220,11 @@ void showOptions(
if (image != null) {
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
}
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
final privacyModeState = PrivacyModeState.find(id);
if (pi.displays.length > 1 &&
pi.currentDisplay != kAllDisplayValue &&
(privacyModeState.isEmpty ||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value))) {
final cur = pi.currentDisplay;
final children = <Widget>[];
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
@@ -1272,8 +1278,6 @@ void showOptions(
await toolbarDisplayToggle(context, id, gFFI);
List<TToggleMenu> privacyModeList = [];
// privacy mode
final privacyModeState = PrivacyModeState.find(id);
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
privacyModeState.isNotEmpty) {
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);

View File

@@ -83,6 +83,13 @@ class _TerminalPageState extends State<TerminalPage>
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Auto-close connection when shell exits
_terminalModel.onClosed = () {
if (mounted) {
closeConnection(id: widget.id);
}
};
// Web desktop users have full hardware keyboard access, so the on-screen
// terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop &&

View File

@@ -259,11 +259,13 @@ class _ViewCameraPageState extends State<ViewCameraPage>
}
return Container(
color: MyTheme.canvasColor,
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
child: inputModel.isPhysicalMouse.value
? getBodyForMobile()
: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
);
}),
),

View File

@@ -117,13 +117,13 @@ void showServerSettingsWithValue(
),
SizedBox(width: 8),
Expanded(
child: TextFormField(
child: serverSettingsTextFormField(
label: label,
controller: controller,
decoration: InputDecoration(
errorText: errorMsg.isEmpty ? null : errorMsg,
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
),
errorMsg: errorMsg,
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
showLabelText: false,
validator: validator,
autofocus: autofocus,
).workaroundFreezeLinuxMint(),
@@ -132,12 +132,10 @@ void showServerSettingsWithValue(
);
}
return TextFormField(
return serverSettingsTextFormField(
label: label,
controller: controller,
decoration: InputDecoration(
labelText: label,
errorText: errorMsg.isEmpty ? null : errorMsg,
),
errorMsg: errorMsg,
validator: validator,
).workaroundFreezeLinuxMint();
}
@@ -209,6 +207,35 @@ void showServerSettingsWithValue(
});
}
TextFormField serverSettingsTextFormField({
required String label,
required TextEditingController controller,
required String errorMsg,
String? Function(String?)? validator,
bool autofocus = false,
bool showLabelText = true,
EdgeInsetsGeometry? contentPadding,
}) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: showLabelText ? label : null,
errorText: errorMsg.isEmpty ? null : errorMsg,
contentPadding: contentPadding,
),
validator: validator,
autofocus: autofocus,
keyboardType: TextInputType.visiblePassword,
textCapitalization: TextCapitalization.none,
autocorrect: false,
enableSuggestions: false,
smartDashesType: SmartDashesType.disabled,
smartQuotesType: SmartQuotesType.disabled,
enableIMEPersonalizedLearning: false,
spellCheckConfiguration: const SpellCheckConfiguration.disabled(),
);
}
void setPrivacyModeDialog(
OverlayDialogManager dialogManager,
List<TToggleMenu> privacyModeList,

View File

@@ -142,12 +142,22 @@ class FileModel {
}
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
final id = int.tryParse(evt['id']?.toString() ?? '');
if (id == null || !jobController.hasTransferConflictJob(id)) {
debugPrint("Ignore stale override confirm event: $evt");
return;
}
evtLoop.pushEvent(
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
}
Future<void> overrideFileConfirm(Map<String, dynamic> evt,
{bool? overrideConfirm, bool skip = false}) async {
final id = int.tryParse(evt['id']?.toString() ?? '') ?? 0;
if (id == 0 || !jobController.hasTransferConflictJob(id)) {
debugPrint("Ignore override confirm for inactive job: $evt");
return;
}
// If `skip == true`, it means to skip this file without showing dialog.
// Because `resp` may be null after the user operation or the last remembered operation,
// and we should distinguish them.
@@ -156,15 +166,12 @@ class FileModel {
? await showFileConfirmDialog(translate("Overwrite"),
"${evt['read_path']}", true, evt['is_identical'] == "true")
: null);
final id = int.tryParse(evt['id']) ?? 0;
if (!jobController.hasTransferConflictJob(id)) {
debugPrint("Ignore override confirm result for inactive job: $evt");
return;
}
if (false == resp) {
final jobIndex = jobController.getJob(id);
if (jobIndex != -1) {
await jobController.cancelJob(id);
final job = jobController.jobTable[jobIndex];
job.state = JobState.done;
jobController.jobTable.refresh();
}
await jobController.cancelTransferConflictBatch(id);
} else {
var need_override = false;
if (resp == null) {
@@ -176,6 +183,7 @@ class FileModel {
}
// Update the loop config.
if (fileConfirmCheckboxRemember) {
jobController.rememberTransferConflictBatch(id, resp);
evtLoop.setSkip(!need_override);
}
await bind.sessionSetConfirmOverrideFile(
@@ -285,6 +293,8 @@ class FileModel {
final isWindows = otherSideData.options.isWindows;
final showHidden = otherSideData.options.showHidden;
final jobID = jobController.addTransferJob(entry, false);
jobController.registerTransferConflictBatch([jobID],
batchId: int.tryParse(obj['batchId']?.toString() ?? ''));
webSendLocalFiles(
handleIndex: handleIndex,
actId: jobID,
@@ -570,8 +580,15 @@ class FileController {
final toPath = otherSideData.directory.path;
final isWindows = otherSideData.options.isWindows;
final showHidden = otherSideData.options.showHidden;
final transferJobs = <(Entry, int)>[];
final transferJobIds = <int>[];
for (var from in items.items) {
final jobID = jobController.addTransferJob(from, isRemoteToLocal);
transferJobs.add((from, jobID));
transferJobIds.add(jobID);
}
jobController.registerTransferConflictBatch(transferJobIds);
for (final (from, jobID) in transferJobs) {
bind.sessionSendFiles(
sessionId: sessionId,
actId: jobID,
@@ -917,6 +934,10 @@ class JobController {
static final JobID jobID = JobID();
final jobTable = List<JobProgress>.empty(growable: true).obs;
final jobResultListener = JobResultListener<Map<String, dynamic>>();
int _nextTransferConflictBatchId = 1;
final Map<int, int> _transferConflictJobToBatch = {};
int? _transferConflictRememberBatchId;
bool? _transferConflictRememberOverrideConfirm;
final GetSessionID getSessionID;
final GetDialogManager getDialogManager;
SessionID get sessionId => getSessionID();
@@ -929,6 +950,57 @@ class JobController {
return jobTable.indexWhere((element) => element.id == id);
}
void registerTransferConflictBatch(Iterable<int> jobIds, {int? batchId}) {
final ids = jobIds.toList(growable: false);
if (ids.isEmpty) {
return;
}
batchId ??= _nextTransferConflictBatchId++;
if (batchId >= _nextTransferConflictBatchId) {
_nextTransferConflictBatchId = batchId + 1;
}
for (final jobId in ids) {
_transferConflictJobToBatch[jobId] = batchId;
}
}
int? transferConflictBatchId(int jobId) {
return _transferConflictJobToBatch[jobId];
}
bool hasTransferConflictJob(int jobId) {
return transferConflictBatchId(jobId) != null;
}
bool isTransferConflictRememberBatch(int? batchId) {
return batchId != null && batchId == _transferConflictRememberBatchId;
}
bool? transferConflictRememberOverrideConfirm(int? batchId) {
if (!isTransferConflictRememberBatch(batchId)) {
return null;
}
return _transferConflictRememberOverrideConfirm;
}
void rememberTransferConflictBatch(int jobId, bool? overrideConfirm) {
_transferConflictRememberBatchId = _transferConflictJobToBatch[jobId];
_transferConflictRememberOverrideConfirm = overrideConfirm;
}
void unregisterTransferConflictJob(int jobId) {
final batchId = _transferConflictJobToBatch.remove(jobId);
if (batchId == null) {
return;
}
if (!_transferConflictJobToBatch.containsValue(batchId)) {
if (_transferConflictRememberBatchId == batchId) {
_transferConflictRememberBatchId = null;
_transferConflictRememberOverrideConfirm = null;
}
}
}
// return jobID
int addTransferJob(Entry from, bool isRemoteToLocal) {
final jobID = JobController.jobID.next();
@@ -1000,7 +1072,10 @@ class JobController {
id = int.parse(evt['id']);
} catch (_) {}
final jobIndex = getJob(id);
if (jobIndex == -1) return true;
if (jobIndex == -1) {
unregisterTransferConflictJob(id);
return true;
}
final job = jobTable[jobIndex];
job.recvJobRes = true;
if (job.type == JobType.deleteFile) {
@@ -1026,6 +1101,9 @@ class JobController {
job.state = JobState.done;
}
jobTable.refresh();
if (job.state == JobState.done || job.state == JobState.error) {
unregisterTransferConflictJob(id);
}
if (job.type == JobType.deleteDir) {
return job.state == JobState.done;
} else {
@@ -1035,9 +1113,15 @@ class JobController {
void jobError(Map<String, dynamic> evt) {
final err = evt['err'].toString();
int jobIndex = getJob(int.parse(evt['id']));
final id = int.tryParse(evt['id']?.toString() ?? '');
if (id == null) {
debugPrint("Ignore job error with invalid id: $evt");
return;
}
int jobIndex = getJob(id);
if (jobIndex != -1) {
final job = jobTable[jobIndex];
if (job.state == JobState.done && job.err == "cancel") return;
job.state = JobState.error;
job.err = err;
job.recvJobRes = true;
@@ -1060,6 +1144,11 @@ class JobController {
}
}
jobTable.refresh();
if (job.state == JobState.done || job.state == JobState.error) {
unregisterTransferConflictJob(job.id);
}
} else {
unregisterTransferConflictJob(id);
}
if (err == _kOneWayFileTransferError) {
if (DateTime.now().millisecondsSinceEpoch - _lastTimeShowMsgbox > 3000) {
@@ -1096,9 +1185,42 @@ class JobController {
}
Future<void> cancelJob(int id) async {
unregisterTransferConflictJob(id);
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
}
Future<void> cancelTransferConflictBatch(int jobId) async {
final batchId = _transferConflictJobToBatch[jobId];
final batchJobIds = batchId == null ? [jobId] : <int>[];
if (batchId != null) {
for (final entry in _transferConflictJobToBatch.entries) {
if (entry.value == batchId) {
batchJobIds.add(entry.key);
}
}
for (final id in batchJobIds) {
unregisterTransferConflictJob(id);
}
}
final jobIdsToCancel = batchJobIds.toSet();
for (final job in jobTable) {
if (!jobIdsToCancel.contains(job.id) || job.state == JobState.done) {
continue;
}
job.state = JobState.done;
job.err = "cancel";
job.recvJobRes = true;
}
jobTable.refresh();
for (final id in batchJobIds) {
try {
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
} catch (e) {
debugPrint("Failed to cancel transfer job $id in conflict batch: $e");
}
}
}
Future<void> loadLastJob(Map<String, dynamic> evt) async {
debugPrint("load last job: $evt");
Map<String, dynamic> jobDetail = json.decode(evt['value']);
@@ -1145,7 +1267,7 @@ class JobController {
..state = JobState.paused;
jobTable.add(jobProgress);
}
registerTransferConflictBatch([currJobId]);
await bind.sessionAddJob(
sessionId: sessionId,
isRemote: isRemote,
@@ -1193,6 +1315,9 @@ class JobController {
void clear() {
jobTable.clear();
_transferConflictJobToBatch.clear();
_transferConflictRememberBatchId = null;
_transferConflictRememberOverrideConfirm = null;
jobResultListener.clear();
}
}
@@ -1535,6 +1660,9 @@ class JobProgress {
String display() {
if (type == JobType.transfer) {
if (state == JobState.done && err == "cancel") {
return translate("Cancel");
}
if (state == JobState.done && err == "skipped") {
return translate("Skipped");
}
@@ -1844,21 +1972,44 @@ class _FileDialogEvent extends BaseEvent<FileDialogType, Map<String, dynamic>> {
class FileDialogEventLoop
extends BaseEventLoop<FileDialogType, Map<String, dynamic>> {
int? _batchId;
bool? _overrideConfirm;
bool _skip = false;
@override
Future<void> onPreConsume(
BaseEvent<FileDialogType, Map<String, dynamic>> evt) async {
var event = evt as _FileDialogEvent;
final event = evt as _FileDialogEvent;
final model = event.fileModel.target;
final jobId = int.tryParse(evt.data['id']?.toString() ?? '');
final batchId = model == null || jobId == null
? null
: model.jobController.transferConflictBatchId(jobId);
final keepRemembered = model != null &&
model.jobController.isTransferConflictRememberBatch(batchId);
// The loop only preloads the remembered batch choice. The model updates it
// after the user answers the current overwrite dialog.
if (_batchId != batchId && !keepRemembered) {
_batchId = batchId;
_overrideConfirm = null;
_skip = false;
} else {
_batchId = batchId;
}
if (keepRemembered) {
_overrideConfirm =
model.jobController.transferConflictRememberOverrideConfirm(batchId);
_skip = _overrideConfirm == null;
}
event.setOverrideConfirm(_overrideConfirm);
event.setSkip(_skip);
debugPrint(
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} overrideConfirm: $_overrideConfirm, skip: $_skip>");
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} batchId: $_batchId overrideConfirm: $_overrideConfirm, skip: $_skip>");
}
@override
Future<void> onEventsClear() {
_batchId = null;
_overrideConfirm = null;
_skip = false;
return super.onEventsClear();

View File

@@ -1307,7 +1307,8 @@ class InputModel {
}
if (isPhysicalMouse.value) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
edgeScroll: useEdgeScroll);
}
}
@@ -1548,7 +1549,8 @@ class InputModel {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
} else {
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventDown), canvasPosition);
}
}
}
@@ -1570,7 +1572,8 @@ class InputModel {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
} else {
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventUp), canvasPosition);
}
}
}
@@ -1592,12 +1595,40 @@ class InputModel {
}
if (isPhysicalMouse.value) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
edgeScroll: useEdgeScroll);
}
}
}
/// Convert pointer coordinates into the visible remote canvas space.
///
/// On mobile, the remote page body is wrapped in `SafeArea`, but the pointer
/// listener that feeds these events sits outside that subtree. As a result,
/// `event.localPosition` still includes the top/left safe-area inset.
///
/// When the keyboard-visible path shows `KeyHelpTools`, the remote canvas is
/// also shifted downward by `CanvasModel.getAdjustY()`. The downstream mouse
/// mapping logic expects coordinates relative to the visible canvas area, so
/// we subtract both the mobile safe-area padding and the current canvas
/// adjustment before passing the position into mouse mapping.
///
/// Desktop and web desktop continue to use the global position directly
/// because their pointer mapping is window-based.
Offset _pointerPositionForRemoteCanvas(PointerEvent event) {
if (isDesktop || isWebDesktop) {
return event.position;
}
final mediaData = MediaQueryData.fromView(
WidgetsBinding.instance.platformDispatcher.views.first);
final adjustY = parent.target?.canvasModel.getAdjustY() ?? 0.0;
return Offset(
event.localPosition.dx - mediaData.padding.left,
event.localPosition.dy - mediaData.padding.top - adjustY,
);
}
static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
List<RemoteWindowCoords> remoteWindowCoords) async {
final coords =

View File

@@ -55,6 +55,8 @@ import 'package:flutter_hbb/native/custom_cursor.dart'
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
final _constSessionId = Uuid().v4obj();
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
const _restartReconnectSilentDelaySecs = 5;
class CachedPeerData {
Map<String, dynamic> updatePrivacyMode = {};
@@ -110,6 +112,9 @@ class CachedPeerData {
class FfiModel with ChangeNotifier {
CachedPeerData cachedPeerData = CachedPeerData();
PeerInfo _pi = PeerInfo();
int? lastUserDisplay;
int? pendingMonitorRestore;
Timer? _pendingRestoreTimer;
Rect? _rect;
var _inputBlocked = false;
@@ -119,6 +124,7 @@ class FfiModel with ChangeNotifier {
bool _touchMode = false;
late VirtualMouseMode virtualMouseMode;
Timer? _timer;
Timer? _restartReconnectDelayTimer;
var _reconnects = 1;
DateTime? _offlineReconnectStartTime;
bool _viewOnly = false;
@@ -245,11 +251,14 @@ class FfiModel with ChangeNotifier {
clear() {
_pi = PeerInfo();
lastUserDisplay = null;
_cancelPendingMonitorRestore();
_secure = null;
_direct = null;
_inputBlocked = false;
_timer?.cancel();
_timer = null;
resetRestartReconnectState();
clearPermissions();
waitForImageTimer?.cancel();
timerScreenshot?.cancel();
@@ -341,6 +350,7 @@ class FfiModel with ChangeNotifier {
} else if (name == 'connection_ready') {
setConnectionType(peerId, evt['secure'] == 'true',
evt['direct'] == 'true', evt['stream_type'] ?? '');
resetRestartReconnectState();
} else if (name == 'switch_display') {
// switch display is kept for backward compatibility
handleSwitchDisplay(evt, sessionId, peerId);
@@ -922,8 +932,29 @@ class FfiModel with ChangeNotifier {
enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false);
} else if (type == 'restarting') {
showMsgBox(sessionId, type, title, text, link, false, dialogManager,
hasCancel: false);
// Treat restart messages as reconnect control events. Rust still sends
// title/text for legacy UI and translation reuse; Flutter keeps the last
// frame briefly, then shows the Connecting overlay.
if (_restartReconnectDelayTimer == null) {
parent.target?.inputModel.setRelativeMouseMode(false);
_cancelPendingMonitorRestore();
bind.sessionReconnect(sessionId: sessionId, forceRelay: false);
clearPermissions();
// Retry once more after the silent window so restart reconnect attempts
// are spaced by the empirical short cadence instead of only updating UI.
_restartReconnectDelayTimer =
Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () {
_restartReconnectDelayTimer = null;
if (parent.target?.closed == true) {
return;
}
reconnect(dialogManager, sessionId, false);
});
}
} else if (type == 'restarting-show') {
_restartReconnectDelayTimer?.cancel();
_restartReconnectDelayTimer = null;
reconnect(dialogManager, sessionId, false);
} else if (type == 'wait-remote-accept-nook') {
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
@@ -949,6 +980,11 @@ class FfiModel with ChangeNotifier {
}
}
void resetRestartReconnectState() {
_restartReconnectDelayTimer?.cancel();
_restartReconnectDelayTimer = null;
}
/// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline(
@@ -1054,10 +1090,22 @@ class FfiModel with ChangeNotifier {
}
}
void _cancelPendingMonitorRestore() {
_pendingRestoreTimer?.cancel();
_pendingRestoreTimer = null;
pendingMonitorRestore = null;
}
void cancelPendingRestoreTimer() {
_pendingRestoreTimer?.cancel();
_pendingRestoreTimer = null;
}
void reconnect(OverlayDialogManager dialogManager, SessionID sessionId,
bool forceRelay) {
// Disable relative mouse mode before reconnecting to ensure cursor is released.
parent.target?.inputModel.setRelativeMouseMode(false);
_cancelPendingMonitorRestore();
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
clearPermissions();
dialogManager.dismissAll();
@@ -1371,9 +1419,29 @@ class FfiModel with ChangeNotifier {
// now replaced to _updateCurDisplay
updateCurDisplay(sessionId);
}
// After reconnecting, restore the last selected monitor once the canvas is ready.
// Switching earlier can offset the view if the monitor sizes differ.
final last = lastUserDisplay;
pendingMonitorRestore = (!isCache &&
last != null &&
last != currentDisplay &&
bind.sessionGetUseAllMyDisplaysForTheRemoteSession(
sessionId: sessionId) !=
'Y' &&
((last == kAllDisplayValue && _pi.displays.isNotEmpty) ||
(last >= 0 && last < _pi.displays.length)))
? last
: null;
// Fallback if the first image event never reaches this tab (multi-UI).
_pendingRestoreTimer?.cancel();
if (pendingMonitorRestore != null) {
_pendingRestoreTimer = Timer(const Duration(milliseconds: 1500),
() => parent.target?._applyPendingMonitorRestore());
}
if (displays.isNotEmpty) {
_reconnects = 1;
_offlineReconnectStartTime = null;
resetRestartReconnectState();
waitForFirstImage.value = true;
isRefreshing = false;
}
@@ -3666,6 +3734,7 @@ class FFI {
/// Mobile reuse FFI
void mobileReset() {
ffiModel.resetRestartReconnectState();
ffiModel.waitForFirstImage.value = true;
ffiModel.isRefreshing = false;
ffiModel.waitForImageDialogShow.value = true;
@@ -3879,16 +3948,35 @@ class FFI {
}
if (ffiModel.waitForFirstImage.value == true) {
ffiModel.waitForFirstImage.value = false;
ffiModel.cancelPendingRestoreTimer();
ffiModel.resetRestartReconnectState();
dialogManager.dismissAll();
await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle();
await canvasModel.initializeEdgeScrollEdgeThickness();
for (final cb in imageModel.callbacksOnFirstImage) {
cb(id);
try {
await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle();
await canvasModel.initializeEdgeScrollEdgeThickness();
for (final cb in imageModel.callbacksOnFirstImage) {
cb(id);
}
} finally {
_applyPendingMonitorRestore();
}
}
}
void _applyPendingMonitorRestore() {
final restore = ffiModel.pendingMonitorRestore;
ffiModel._cancelPendingMonitorRestore();
if (restore == null || closed) return;
// The display list may have changed since the restore was queued.
final displays = ffiModel.pi.displays;
if ((restore == kAllDisplayValue && displays.isNotEmpty) ||
(restore >= 0 && restore < displays.length)) {
openMonitorInTheSameTab(restore, this, ffiModel.pi,
recordSelection: false, updateCursorPos: false);
}
}
/// Login with [password], choose if the client should [remember] it.
void login(String osUsername, String osPassword, SessionID sessionId,
String password, bool remember) {

View File

@@ -145,23 +145,26 @@ class Peer {
note == other.note;
}
Peer.copy(Peer other)
: this(
id: other.id,
hash: other.hash,
password: other.password,
username: other.username,
hostname: other.hostname,
platform: other.platform,
alias: other.alias,
tags: other.tags.toList(),
forceAlwaysRelay: other.forceAlwaysRelay,
rdpPort: other.rdpPort,
rdpUsername: other.rdpUsername,
loginName: other.loginName,
device_group_name: other.device_group_name,
note: other.note,
sameServer: other.sameServer);
factory Peer.copy(Peer other) {
final peer = Peer(
id: other.id,
hash: other.hash,
password: other.password,
username: other.username,
hostname: other.hostname,
platform: other.platform,
alias: other.alias,
tags: other.tags.toList(),
forceAlwaysRelay: other.forceAlwaysRelay,
rdpPort: other.rdpPort,
rdpUsername: other.rdpUsername,
loginName: other.loginName,
device_group_name: other.device_group_name,
note: other.note,
sameServer: other.sameServer);
peer.online = other.online;
return peer;
}
}
enum UpdateEvent { online, load }

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
@@ -38,6 +37,10 @@ class TerminalModel with ChangeNotifier {
void Function(int w, int h, int pw, int ph)? onResizeExternal;
/// Called when the terminal session ends (shell exits).
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
VoidCallback? onClosed;
Future<void> _handleInput(String data) async {
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
@@ -247,6 +250,33 @@ class TerminalModel with ChangeNotifier {
}
}
static int getExitCodeFromEvt(Map<String, dynamic> evt) {
if (evt.containsKey('exit_code')) {
final v = evt['exit_code'];
if (v is int) {
// Desktop and mobile send exit_code as an int
return v;
} else if (v is String) {
// Web sends exit_code as a string
final parsed = int.tryParse(v);
if (parsed != null) {
return parsed;
} else {
debugPrint(
'[TerminalModel] Failed to parse exit_code as integer: $v. Expected a numeric string.');
return 0;
}
} else {
debugPrint(
'[TerminalModel] Unexpected exit_code type: ${v.runtimeType}, value: $v. Expected int or String.');
return 0;
}
} else {
debugPrint('[TerminalModel] Event does not contain exit_code');
return 0;
}
}
void handleTerminalResponse(Map<String, dynamic> evt) {
final String? type = evt['type'];
final int evtTerminalId = getTerminalIdFromEvt(evt);
@@ -469,10 +499,12 @@ class TerminalModel with ChangeNotifier {
}
void _handleTerminalClosed(Map<String, dynamic> evt) {
final int exitCode = evt['exit_code'] ?? 0;
final int exitCode = getExitCodeFromEvt(evt);
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
_terminalOpened = false;
notifyListeners();
// Auto-close the tab/page
onClosed?.call();
}
void _handleTerminalError(Map<String, dynamic> evt) {

View File

@@ -1914,6 +1914,15 @@ class RustdeskImpl {
throw UnimplementedError("sessionHandleScreenshot");
}
Future<void> sessionSetCommon(
{required UuidValue sessionId, required String key, required String value, dynamic hint}) {
js.context.callMethod('setByName', [
'common',
jsonEncode({'name': key, 'value': value})
]);
return Future.value();
}
String? sessionGetCommonSync(
{required UuidValue sessionId,
required String key,

View File

@@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk
PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2025 Purslane Ltd. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved.

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
version: 1.4.7+65
version: 1.4.9+67
environment:
sdk: '^3.1.0'

View File

@@ -0,0 +1,148 @@
import 'package:flutter_hbb/common/widgets/autocomplete.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_test/flutter_test.dart';
Peer _peer({
required String id,
String alias = '',
String username = '',
String hostname = '',
bool online = false,
}) {
final peer = Peer(
id: id,
username: username,
hostname: hostname,
alias: alias,
platform: '',
tags: [],
hash: '',
password: '',
forceAlwaysRelay: false,
rdpPort: '',
rdpUsername: '',
loginName: '',
device_group_name: '',
note: '',
);
peer.online = online;
return peer;
}
void main() {
test('merged autocomplete peers keep address book metadata and online state',
() {
final peers = mergeAutocompletePeers(
addressBookPeers: [
_peer(id: '123456789', alias: 'Office PC', username: 'ab-user'),
],
lanPeers: [
_peer(id: '123456789', username: 'lan-user', online: true),
],
);
expect(peers, hasLength(1));
expect(peers.single.id, '123456789');
expect(peers.single.alias, 'Office PC');
expect(peers.single.username, 'ab-user');
expect(peers.single.online, isTrue);
});
test('peer copies preserve online state', () {
final peer = _peer(id: '987654321', online: true);
expect(Peer.copy(peer).online, isTrue);
});
test('online callbacks update autocomplete-only peers', () {
final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']);
final changed = updateAutocompletePeerOnlineStates(
peers,
onlines: {'112233445'},
offlines: {},
);
expect(changed, isTrue);
expect(peers.single.online, isTrue);
});
test('online query ids are deduplicated and limited', () {
final peers = List.generate(
25,
(index) => _peer(id: index.toString()),
)..insert(1, _peer(id: '0'));
final ids = autocompleteOnlineQueryIds(peers, limit: 20);
expect(ids, hasLength(20));
expect(ids.first, '0');
expect(ids.where((id) => id == '0'), hasLength(1));
expect(ids.last, '19');
});
test('empty online query ids cancel pending debounce', () async {
final queriedIds = <List<String>>[];
final loader = AllPeersLoader(
queryOnlines: (ids) async {
queriedIds.add(ids);
},
queryOnlineDebounce: Duration(milliseconds: 1),
);
loader.queryOnlines([_peer(id: '123456789')]);
loader.queryOnlines([]);
await Future.delayed(Duration(milliseconds: 2));
expect(queriedIds, isEmpty);
});
test('failed online query enqueue does not suppress retry', () async {
var queryCount = 0;
final loader = AllPeersLoader(
queryOnlines: (ids) {
queryCount += 1;
return Future<void>.error(Exception('queue full'));
},
queryOnlineDebounce: Duration(milliseconds: 1),
);
loader.queryOnlines([_peer(id: '123456789')]);
await Future.delayed(Duration(milliseconds: 2));
loader.queryOnlines([_peer(id: '123456789')]);
await Future.delayed(Duration(milliseconds: 2));
expect(queryCount, 2);
});
test('online callback updates currently displayed options', () async {
final loader = AllPeersLoader(
queryOnlines: (ids) async {},
queryOnlineDebounce: Duration(milliseconds: 1),
);
final displayedOptions = [_peer(id: '123456789')];
loader.queryOnlines(displayedOptions);
loader.updateOnlineStateForTesting({
'onlines': '123456789',
'offlines': '',
});
expect(displayedOptions.single.online, isTrue);
await Future.delayed(Duration(milliseconds: 2));
});
test('cached online callback state is reapplied after peers merge', () {
final loader = AllPeersLoader();
loader.updateOnlineStateForTesting({
'onlines': '123456789',
'offlines': '',
});
final mergedPeers = [_peer(id: '123456789')];
loader.applyLastOnlineStateForTesting(mergedPeers);
expect(mergedPeers.single.online, isTrue);
});
}

63
flutter/test/cm_demo.dart Normal file
View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/server_page.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import 'package:window_manager/window_manager.dart';
final testClients = [
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false),
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false),
Client(2, false, false, false, "UserC", "331123123", true, false, false),
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false,
false)
];
/// flutter run -d {platform} -t test/cm_demo.dart to test cm
void main() async {
isTest = true;
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
await windowManager.setSize(const Size(400, 600));
await windowManager.setAlignment(Alignment.topRight);
await initEnv(kAppTypeMain);
for (var client in testClients) {
gFFI.serverModel.clients.add(client);
gFFI.serverModel.tabController.add(TabInfo(
key: client.id.toString(),
label: client.name,
closable: false,
page: buildConnectionCard(client)));
}
runApp(GetMaterialApp(
debugShowCheckedModeBanner: false,
theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme,
themeMode: MyTheme.currentThemeMode(),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: supportedLocales,
home: const DesktopServerPage()));
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
// ensure initial window size to be changed
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
await Future.wait([
windowManager.setAlignment(Alignment.topRight),
windowManager.focus(),
windowManager.setOpacity(1)
]);
// ensure
windowManager.setAlignment(Alignment.topRight);
});
}

View File

@@ -1,62 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/server_page.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import 'package:window_manager/window_manager.dart';
import 'package:flutter_test/flutter_test.dart';
final testClients = [
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
];
import 'cm_demo.dart' as cm_demo;
/// flutter run -d {platform} -t test/cm_test.dart to test cm
void main(List<String> args) async {
isTest = true;
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
await windowManager.setSize(const Size(400, 600));
await windowManager.setAlignment(Alignment.topRight);
await initEnv(kAppTypeMain);
for (var client in testClients) {
gFFI.serverModel.clients.add(client);
gFFI.serverModel.tabController.add(TabInfo(
key: client.id.toString(),
label: client.name,
closable: false,
page: buildConnectionCard(client)));
}
runApp(GetMaterialApp(
debugShowCheckedModeBanner: false,
theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme,
themeMode: MyTheme.currentThemeMode(),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: supportedLocales,
home: const DesktopServerPage()));
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
// ensure initial window size to be changed
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
await Future.wait([
windowManager.setAlignment(Alignment.topRight),
windowManager.focus(),
windowManager.setOpacity(1)
void main() {
test('connection manager demo clients match the current Client API', () {
expect(cm_demo.testClients, hasLength(4));
expect(cm_demo.testClients.map((client) => client.name), [
'UserAAAAAA',
'UserBBBBB',
'UserC',
'UserDDDDDDDDDDDd',
]);
// ensure
windowManager.setAlignment(Alignment.topRight);
expect(
cm_demo.testClients.every(
(client) => client.keyboard && !client.clipboard && !client.audio),
isTrue,
);
});
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
void main() {
testWidgets('server settings text fields preserve literal input',
(tester) async {
final controller = TextEditingController(text: 'AbCdR1c1E=');
addTearDown(controller.dispose);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: serverSettingsTextFormField(
label: 'Key',
controller: controller,
errorMsg: '',
autofocus: true,
),
),
));
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.controller, controller);
expect(textField.autofocus, isTrue);
expect(textField.keyboardType, TextInputType.visiblePassword);
expect(textField.textCapitalization, TextCapitalization.none);
expect(textField.autocorrect, isFalse);
expect(textField.enableSuggestions, isFalse);
expect(textField.smartDashesType, SmartDashesType.disabled);
expect(textField.smartQuotesType, SmartQuotesType.disabled);
expect(textField.enableIMEPersonalizedLearning, isFalse);
expect(
textField.spellCheckConfiguration,
const SpellCheckConfiguration.disabled(),
);
});
}

View File

@@ -89,11 +89,11 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "Purslane Ltd" "\0"
VALUE "CompanyName", "Purslane Tech Pte. Ltd." "\0"
VALUE "FileDescription", "RustDesk Remote Desktop" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "rustdesk" "\0"
VALUE "LegalCopyright", "Copyright © 2025 Purslane Ltd. All rights reserved." "\0"
VALUE "LegalCopyright", "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved." "\0"
VALUE "OriginalFilename", "rustdesk.exe" "\0"
VALUE "ProductName", "RustDesk" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"

View File

@@ -14,6 +14,7 @@
typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*);
typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int);
typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, int);
typedef int (*FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)();
/// Note: `--server`, `--service` are already handled in [core_main.rs].
const std::vector<std::string> parameters_white_list = {"--install", "--cm"};
@@ -62,6 +63,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
}
std::vector<std::string> rust_args(c_args, c_args + args_len);
free_c_args(c_args, args_len);
FUNC_RUSTDESK_IS_DISABLE_INSTALLATION rustdesk_is_disable_installation =
(FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)GetProcAddress(hInstance, "rustdesk_is_disable_installation");
bool is_disable_installation =
rustdesk_is_disable_installation && rustdesk_is_disable_installation() != 0;
const auto installParam = std::string("--install");
// Flutter reads the original process command line, not only rust_args, so
// remove the `--install` injected by the portable wrapper here as well. This
// also lets `no-install.exe` continue as a portable app when installation is
// disabled. See: https://github.com/rustdesk/rustdesk-server-pro/issues/991#issuecomment-4978376890
if (is_disable_installation) {
command_line_arguments.erase(
std::remove(command_line_arguments.begin(),
command_line_arguments.end(),
installParam),
command_line_arguments.end());
}
std::wstring app_name = L"RustDesk";
FUNC_RUSTDESK_GET_APP_NAME get_rustdesk_app_name = (FUNC_RUSTDESK_GET_APP_NAME)GetProcAddress(hInstance, "get_rustdesk_app_name");
@@ -118,7 +135,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
is_cm_page = true;
}
bool is_install_page = false;
auto installParam = std::string("--install");
if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) {
is_install_page = true;
}

View File

@@ -533,7 +533,7 @@ impl FuseServer {
offset: i64,
size: u32,
) -> Result<Vec<u8>, std::io::Error> {
// todo: async and concurrent read, generate stream_id per request
let request_stream_id = rand::random();
let cb_requested = unsafe {
// convert `size` from u32 to i32
// yet with same bit representation
@@ -543,7 +543,7 @@ impl FuseServer {
let (n_position_high, n_position_low) =
((offset >> 32) as i32, (offset & (u32::MAX as i64)) as i32);
let request = ClipboardFile::FileContentsRequest {
stream_id: node.stream_id,
stream_id: request_stream_id,
list_index: node.index as i32,
dw_flags: 2,
n_position_low,
@@ -573,7 +573,7 @@ impl FuseServer {
stream_id,
requested_data,
} => {
if stream_id != node.stream_id {
if stream_id != request_stream_id {
log::debug!("stream id mismatch, ignore");
continue;
}
@@ -611,11 +611,6 @@ struct FuseNode {
/// connection id
pub conn_id: i32,
// todo: use stream_id to identify a FileContents request-reply
// instead of a whole file
/// stream id
pub stream_id: i32,
/// file index in peer's file list
/// NOTE:
/// it is NOT the same as inode, this is the index in the file list
@@ -639,7 +634,6 @@ impl FuseNode {
pub fn from_description(inode: Inode, desc: FileDescription) -> Self {
Self {
conn_id: desc.conn_id,
stream_id: rand::random(),
index: inode as usize - 2,
name: desc
.name
@@ -656,7 +650,6 @@ impl FuseNode {
pub fn new_root() -> Self {
Self {
conn_id: 0,
stream_id: rand::random(),
index: 0,
name: String::from("/"),
parent: None,

View File

@@ -4,23 +4,24 @@ use super::filetype::FileDescription;
use crate::{ClipboardFile, CliprdrError};
use cs::FuseServer;
use fuser::MountOption;
use hbb_common::{config::APP_NAME, log};
use hbb_common::{config::Config, log};
use parking_lot::Mutex;
use std::{
path::PathBuf,
io,
path::{Path, PathBuf},
sync::{mpsc::Sender, Arc},
time::Duration,
};
lazy_static::lazy_static! {
static ref FUSE_MOUNT_POINT_CLIENT: Arc<String> = {
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-client");
let mnt_path = fuse_mount_point("cliprdr-client");
// No need to run `canonicalize()` here.
Arc::new(mnt_path)
};
static ref FUSE_MOUNT_POINT_SERVER: Arc<String> = {
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-server");
let mnt_path = fuse_mount_point("cliprdr-server");
// No need to run `canonicalize()` here.
Arc::new(mnt_path)
};
@@ -31,6 +32,21 @@ lazy_static::lazy_static! {
static FUSE_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, PartialEq, Eq)]
enum MountPointState {
HealthyMount,
NotMounted,
StaleMount,
Unknown,
}
fn fuse_mount_point(name: &str) -> String {
let mut path = PathBuf::from(Config::ipc_path(""));
path.pop();
path.push(name);
path.to_string_lossy().to_string()
}
pub fn get_exclude_paths(is_client: bool) -> Arc<String> {
if is_client {
FUSE_MOUNT_POINT_CLIENT.clone()
@@ -53,8 +69,27 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
} else {
FUSE_CONTEXT_SERVER.lock()
};
if fuse_context_lock.is_some() {
return Ok(());
if let Some(ctx) = fuse_context_lock.as_ref() {
match inspect_mount_point_state(&ctx.mount_point) {
MountPointState::HealthyMount => return Ok(()),
MountPointState::StaleMount | MountPointState::NotMounted => {
log::warn!(
"clipboard FUSE mount {} is disconnected, remounting",
ctx.mount_point.display()
);
let stale_context = fuse_context_lock.take();
drop(fuse_context_lock);
drop(stale_context);
return init_fuse_context(is_client);
}
MountPointState::Unknown => {
log::warn!(
"failed to verify clipboard FUSE mount {}",
ctx.mount_point.display()
);
return Err(CliprdrError::CliprdrInit);
}
}
}
let mount_point = if is_client {
FUSE_MOUNT_POINT_CLIENT.clone()
@@ -63,10 +98,32 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
};
let mount_point = std::path::PathBuf::from(&*mount_point);
match inspect_mount_point_state(&mount_point) {
MountPointState::HealthyMount => {
log::warn!(
"clipboard FUSE mount {} is already active in another context",
mount_point.display()
);
return Err(CliprdrError::ClipboardOccupied);
}
MountPointState::StaleMount => {
log::warn!(
"clipboard FUSE mount {} is stale, cleaning up before remount",
mount_point.display()
);
unmount_fuse_mount_point(&mount_point);
validate_mount_state_after_stale_cleanup(
&mount_point,
inspect_mount_point_state(&mount_point),
)?;
}
MountPointState::Unknown => return Err(CliprdrError::CliprdrInit),
MountPointState::NotMounted => {}
}
let (server, tx) = FuseServer::new(FUSE_TIMEOUT);
let server = Arc::new(Mutex::new(server));
prepare_fuse_mount_point(&mount_point);
prepare_fuse_mount_point(&mount_point)?;
let mnt_opts = [
MountOption::FSName("rustdesk-cliprdr-fs".to_string()),
MountOption::NoAtime,
@@ -159,35 +216,246 @@ struct FuseContext {
}
// this function must be called after the main IPC is up
fn prepare_fuse_mount_point(mount_point: &PathBuf) {
fn prepare_fuse_mount_point(mount_point: &PathBuf) -> Result<(), CliprdrError> {
use std::{
fs::{self, Permissions},
os::unix::prelude::PermissionsExt,
};
fs::create_dir(mount_point).ok();
fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok();
if let Some(parent) = mount_point.parent() {
reject_symlink_path(parent)?;
if let Err(e) = fs::create_dir_all(parent) {
log::warn!("failed to create FUSE mount parent {:?}: {:?}", parent, e);
return Err(CliprdrError::CliprdrInit);
}
}
if let Err(e) = std::process::Command::new("umount")
.arg(mount_point)
.status()
{
log::warn!("umount {:?} may fail: {:?}", mount_point, e);
reject_symlink_path(mount_point)?;
let recovered_stale_mount = if let Err(e) = fs::create_dir_all(mount_point) {
log::warn!(
"failed to create clipboard FUSE mount point {}, trying stale mount cleanup: {:?}",
mount_point.display(),
e
);
unmount_fuse_mount_point(mount_point);
fs::create_dir_all(mount_point).map_err(|e| {
log::error!(
"failed to create clipboard FUSE mount point {} after cleanup: {:?}",
mount_point.display(),
e
);
CliprdrError::CliprdrInit
})?;
true
} else {
false
};
if let Err(e) = fs::set_permissions(mount_point, Permissions::from_mode(0o777)) {
log::warn!(
"failed to set clipboard FUSE mount point permissions {}: {:?}",
mount_point.display(),
e
);
}
if !recovered_stale_mount {
unmount_fuse_mount_point(mount_point);
}
Ok(())
}
fn inspect_mount_point_state(mount_point: &Path) -> MountPointState {
if ensure_mount_point_path_is_safe(mount_point).is_err() {
return MountPointState::Unknown;
}
inspect_mount_point_state_with(
mount_point,
std::fs::metadata(mount_point),
std::fs::read_to_string("/proc/self/mountinfo"),
)
}
fn validate_mount_state_after_stale_cleanup(
mount_point: &Path,
mount_state: MountPointState,
) -> Result<(), CliprdrError> {
match mount_state {
MountPointState::NotMounted => Ok(()),
MountPointState::HealthyMount => {
log::warn!(
"clipboard FUSE mount {} is still active after stale cleanup",
mount_point.display()
);
Err(CliprdrError::ClipboardOccupied)
}
MountPointState::StaleMount => {
log::warn!(
"clipboard FUSE mount {} is still stale after cleanup",
mount_point.display()
);
Err(CliprdrError::CliprdrInit)
}
MountPointState::Unknown => {
log::warn!(
"failed to verify clipboard FUSE mount {} after cleanup",
mount_point.display()
);
Err(CliprdrError::CliprdrInit)
}
}
}
fn uninit_fuse_context_(is_client: bool) {
if is_client {
let _ = FUSE_CONTEXT_CLIENT.lock().take();
} else {
let _ = FUSE_CONTEXT_SERVER.lock().take();
fn inspect_mount_point_state_with<T>(
mount_point: &Path,
metadata_result: io::Result<T>,
mountinfo_result: io::Result<String>,
) -> MountPointState {
match metadata_result {
Ok(_) => match mountinfo_result {
Ok(mountinfo) => {
if is_mount_point_listed_in_mountinfo(mount_point, &mountinfo) {
MountPointState::HealthyMount
} else {
MountPointState::NotMounted
}
}
Err(e) => {
log::warn!("failed to read mountinfo for {:?}: {:?}", mount_point, e);
MountPointState::Unknown
}
},
Err(e) if e.raw_os_error() == Some(libc::ENOTCONN) => MountPointState::StaleMount,
Err(e) if e.kind() == io::ErrorKind::NotFound => MountPointState::NotMounted,
Err(e) => {
log::warn!("failed to inspect FUSE mount {:?}: {:?}", mount_point, e);
MountPointState::Unknown
}
}
}
fn is_mount_point_listed_in_mountinfo(mount_point: &Path, mountinfo: &str) -> bool {
let mount_point = mount_point.to_string_lossy();
mountinfo.lines().any(|line| {
let mut fields = line.split_whitespace();
let _mount_id = fields.next();
let _parent_id = fields.next();
let _major_minor = fields.next();
let _root = fields.next();
let mount_path = fields.next();
mount_path == Some(mount_point.as_ref())
})
}
fn reject_symlink_metadata_result(
path: &Path,
metadata_result: io::Result<std::fs::Metadata>,
allow_disconnected_mount: bool,
) -> Result<(), CliprdrError> {
match metadata_result {
Ok(metadata) if metadata.file_type().is_symlink() => {
log::warn!("refusing to use symlinked FUSE path {:?}", path);
Err(CliprdrError::CliprdrInit)
}
Ok(_) => Ok(()),
Err(e) if allow_disconnected_mount && e.raw_os_error() == Some(libc::ENOTCONN) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => {
log::warn!("failed to inspect FUSE path {:?}: {:?}", path, e);
Err(CliprdrError::CliprdrInit)
}
}
}
fn reject_symlink_path(path: &Path) -> Result<(), CliprdrError> {
reject_symlink_metadata_result(path, std::fs::symlink_metadata(path), false)
}
fn ensure_mount_point_path_is_safe(mount_point: &Path) -> Result<(), CliprdrError> {
if let Some(parent) = mount_point.parent() {
reject_symlink_path(parent)?;
}
reject_symlink_metadata_result(mount_point, std::fs::symlink_metadata(mount_point), true)
}
fn unmount_fuse_mount_point(mount_point: &Path) {
if ensure_mount_point_path_is_safe(mount_point).is_err() {
log::warn!(
"refusing to unmount unsafe clipboard FUSE mount point {:?}",
mount_point
);
return;
}
if inspect_mount_point_state_with(
mount_point,
std::fs::metadata(mount_point),
std::fs::read_to_string("/proc/self/mountinfo"),
) == MountPointState::NotMounted
{
return;
}
for (program, args) in unmount_command_candidates() {
if run_unmount_command(program, args, mount_point) {
return;
}
}
log::warn!(
"failed to unmount clipboard FUSE mount point {:?}",
mount_point
);
}
fn unmount_command_candidates() -> [(&'static str, &'static [&'static str]); 3] {
[
("fusermount3", &["-uz"]),
("fusermount", &["-uz"]),
("umount", &["-l"]),
]
}
fn run_unmount_command(program: &str, args: &[&str], mount_point: &Path) -> bool {
match std::process::Command::new(program)
.args(args)
.arg(mount_point)
.status()
{
Ok(status) if status.success() => {}
Ok(status) => {
log::debug!(
"{} {:?} exited with status {:?}",
program,
mount_point,
status.code()
);
return false;
}
Err(e) => {
log::debug!("failed to run {} for {:?}: {:?}", program, mount_point, e);
return false;
}
}
true
}
fn uninit_fuse_context_(is_client: bool) {
let ctx = {
let mut fuse_context_lock = if is_client {
FUSE_CONTEXT_CLIENT.lock()
} else {
FUSE_CONTEXT_SERVER.lock()
};
fuse_context_lock.take()
};
drop(ctx);
}
impl Drop for FuseContext {
fn drop(&mut self) {
self.session.lock().take().map(|s| s.join());
log::info!("unmounting clipboard FUSE from {}", self.mount_point.display());
log::info!(
"unmounting clipboard FUSE from {}",
self.mount_point.display()
);
}
}
@@ -223,3 +491,66 @@ impl FuseContext {
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, io};
#[cfg(target_family = "unix")]
use std::os::unix::fs::symlink;
#[test]
fn classifies_mount_point_state_from_metadata_and_mountinfo() {
let mount_point = std::env::temp_dir().join(format!(
"rustdesk-fuse-mount-state-test-{}-{}",
std::process::id(),
line!()
));
let mountinfo = format!(
"123 1 0:45 / {} rw,nosuid,nodev - fuse.rustdesk rustdesk rw\n",
mount_point.display()
);
assert_eq!(
inspect_mount_point_state_with(&mount_point, Ok(()), Ok(mountinfo)),
MountPointState::HealthyMount
);
assert_eq!(
inspect_mount_point_state_with(&mount_point, Ok(()), Ok(String::new())),
MountPointState::NotMounted
);
let disconnected_metadata: io::Result<()> =
Err(io::Error::from_raw_os_error(libc::ENOTCONN));
assert_eq!(
inspect_mount_point_state_with(&mount_point, disconnected_metadata, Ok(String::new())),
MountPointState::StaleMount
);
}
#[test]
#[cfg(target_family = "unix")]
fn rejects_symlink_mount_point() {
let base = std::env::temp_dir().join(format!(
"rustdesk-fuse-symlink-test-{}-{}",
std::process::id(),
line!()
));
let mount_parent = base.join("parent");
let mount_point = mount_parent.join("cliprdr-client");
let symlink_target = base.join("symlink-target");
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
fs::create_dir_all(&mount_parent).unwrap();
fs::create_dir_all(&symlink_target).unwrap();
symlink(&symlink_target, &mount_point).unwrap();
assert!(matches!(
prepare_fuse_mount_point(&mount_point),
Err(CliprdrError::CliprdrInit)
));
let _ = fs::remove_dir_all(&base);
}
}

View File

@@ -192,20 +192,16 @@ impl LocalFile {
});
};
if offset != self.offset.load(Ordering::Relaxed) {
let read_result = if offset != self.offset.load(Ordering::Relaxed) {
handle
.seek(std::io::SeekFrom::Start(offset))
.map_err(|e| CliprdrError::FileError {
path: self.path.to_string_lossy().to_string(),
err: e,
})?;
.and_then(|_| handle.read_exact(buf))
} else {
handle.read_exact(buf)
};
if let Err(e) = read_result {
return Err(self.invalidate_handle(e));
}
handle
.read_exact(buf)
.map_err(|e| CliprdrError::FileError {
path: self.path.to_string_lossy().to_string(),
err: e,
})?;
let new_offset = offset + (buf.len() as u64);
self.offset.store(new_offset, Ordering::Relaxed);
@@ -217,6 +213,15 @@ impl LocalFile {
Ok(())
}
fn invalidate_handle(&mut self, err: std::io::Error) -> CliprdrError {
self.offset.store(0, Ordering::Relaxed);
self.handle = None;
CliprdrError::FileError {
path: self.path.to_string_lossy().to_string(),
err,
}
}
}
pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, CliprdrError> {
@@ -278,7 +283,10 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
#[cfg(test)]
mod file_list_test {
use std::{path::PathBuf, sync::atomic::AtomicU64};
use std::{
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use hbb_common::bytes::{BufMut, BytesMut};
@@ -384,4 +392,30 @@ mod file_list_test {
as_bin_parse_test("/test")?;
Ok(())
}
#[test]
fn read_exact_at_reopens_after_read_failure() -> Result<(), Box<dyn std::error::Error>> {
let file_path = std::env::temp_dir().join(format!(
"rustdesk-clipboard-local-file-{}",
std::process::id()
));
std::fs::write(&file_path, b"")?;
let mut file = LocalFile::try_open(&std::env::temp_dir(), &file_path)?;
file.size = 1;
let mut buf = [0u8; 1];
assert!(file.read_exact_at(&mut buf, 0).is_err());
assert!(file.handle.is_none());
assert_eq!(file.offset.load(Ordering::Relaxed), 0);
std::fs::write(&file_path, [42u8])?;
file.read_exact_at(&mut buf, 0)?;
assert_eq!(buf, [42u8]);
assert!(file.handle.is_none());
std::fs::remove_file(file_path)?;
Ok(())
}
}

View File

@@ -5,7 +5,7 @@ use hbb_common::{
log,
};
use parking_lot::Mutex;
use std::{path::PathBuf, sync::Arc, usize};
use std::{path::PathBuf, sync::Arc, time::SystemTime, usize};
lazy_static::lazy_static! {
// local files are cached, this value should not be changed when copying files
@@ -30,9 +30,35 @@ enum FileContentsRequest {
},
}
// Cheap fingerprint of one top-level selected entry. A change in size/mtime --
// or a directory in the selection -- forces sync_files() to rebuild (see below).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct FileSig {
size: u64,
mtime: Option<SystemTime>,
is_dir: bool,
}
// Stat the top-level selected paths only (no recursion), same order as `files`.
fn fingerprint(files: &[String]) -> Vec<FileSig> {
files
.iter()
.map(|s| match std::fs::metadata(s) {
Ok(mt) => FileSig {
size: mt.len(),
mtime: mt.modified().ok(),
is_dir: mt.is_dir(),
},
Err(_) => FileSig::default(),
})
.collect()
}
#[derive(Default)]
struct ClipFiles {
files: Vec<String>,
// Fingerprint of `files` (same len/order); detects in-place edits on re-copy.
sigs: Vec<FileSig>,
file_list: Vec<LocalFile>,
first_file_index: usize,
files_pdu: Vec<u8>,
@@ -41,12 +67,17 @@ struct ClipFiles {
impl ClipFiles {
fn clear(&mut self) {
self.files.clear();
self.sigs.clear();
self.file_list.clear();
self.first_file_index = usize::MAX;
self.files_pdu.clear();
}
fn sync_files(&mut self, clipboard_files: &[String]) -> Result<(), CliprdrError> {
fn sync_files(
&mut self,
clipboard_files: &[String],
sigs: Vec<FileSig>,
) -> Result<(), CliprdrError> {
let clipboard_paths = clipboard_files
.iter()
.map(|s| PathBuf::from(s))
@@ -58,6 +89,7 @@ impl ClipFiles {
.position(|f| !f.path.is_dir())
.unwrap_or(usize::MAX);
self.files = clipboard_files.to_vec();
self.sigs = sigs;
Ok(())
}
@@ -258,14 +290,128 @@ pub fn read_file_contents(
}
pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> {
// Dedup: skip the rebuild only when paths + sizes + mtimes match and no dir is
// selected (a dir's own mtime doesn't change when a file inside it is edited).
let current = fingerprint(files);
let mut files_lock = CLIP_FILES.lock();
if files_lock.files == files {
if files_lock.files == files
&& files_lock.sigs == current
&& !current.iter().any(|sig| sig.is_dir)
{
return Ok(());
}
files_lock.sync_files(files)?;
files_lock.sync_files(files, current)?;
Ok(files_lock.build_file_list_pdu())
}
pub fn get_file_list_pdu() -> Vec<u8> {
CLIP_FILES.lock().files_pdu.clone()
}
#[cfg(test)]
mod sig_test {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
// Unique temp dir under the system temp dir; removed on drop (no dev-dep).
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut dir = std::env::temp_dir();
dir.push(format!("rustdesk_sig_test_{}_{}", tag, nanos));
fs::create_dir_all(&dir).unwrap();
TmpDir(dir)
}
fn join(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn path_str(p: &PathBuf) -> String {
p.to_string_lossy().to_string()
}
#[test]
fn fingerprint_missing_path_is_default() {
let tmp = TmpDir::new("missing");
let missing = path_str(&tmp.join("does_not_exist.bin"));
let sigs = fingerprint(&[missing]);
assert_eq!(sigs.len(), 1);
// A path that can't be stat'd -> default sig, which forces a rebuild.
assert_eq!(sigs[0], FileSig::default());
assert_eq!(sigs[0].mtime, None);
}
#[test]
fn fingerprint_detects_inplace_edit() {
let tmp = TmpDir::new("edit");
let file = tmp.join("a.bin");
fs::write(&file, b"small").unwrap();
let p = path_str(&file);
let before = fingerprint(&[p.clone()]);
// Same content, same path: fingerprint must be stable.
let again = fingerprint(&[p.clone()]);
assert_eq!(before, again);
assert_eq!(before[0].size, 5);
assert!(!before[0].is_dir);
// Edit in place so the file grows.
fs::write(&file, b"much larger contents than before").unwrap();
let after = fingerprint(&[p]);
assert_ne!(before, after);
assert!(after[0].size > before[0].size);
}
#[test]
fn fingerprint_flags_directory() {
let tmp = TmpDir::new("dir");
let sub = tmp.join("subdir");
fs::create_dir_all(&sub).unwrap();
let sigs = fingerprint(&[path_str(&sub)]);
assert_eq!(sigs.len(), 1);
assert!(sigs[0].is_dir);
}
#[test]
fn recopy_after_edit_refreshes_cached_size() {
let tmp = TmpDir::new("recopy");
let file = tmp.join("doc.bin");
fs::write(&file, b"v1").unwrap(); // 2 bytes
let files = vec![path_str(&file)];
// Drive the public, guarded `sync_files` over the global CLIP_FILES;
// reset first (this is the only test that touches the global).
clear_files();
sync_files(&files).unwrap();
{
let cache = CLIP_FILES.lock();
let idx = cache.first_file_index;
assert_eq!(cache.file_list[idx].size, 2);
}
// In-place edit grows the file; the re-copy must rebuild. Pre-fix the
// path-only guard early-returned and left the cached size stale at 2.
fs::write(&file, b"v2 is bigger").unwrap(); // 12 bytes
sync_files(&files).unwrap();
{
let cache = CLIP_FILES.lock();
let idx = cache.first_file_index;
assert_eq!(cache.file_list[idx].size, 12);
}
clear_files(); // leave the global clean for other tests
}
}

View File

@@ -41,6 +41,14 @@
/* Maximum number of clipboard streams accepted from a remote peer (integer overflow / DoS guard) */
#define WF_CLIPRDR_MAX_STREAMS 16384
/* Registered clipboard formats use IDs 0xC000 through 0xFFFF.
* https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformatw */
#define WF_CLIPRDR_MAX_FORMATS 0x4000u
/* Registered format names are string atoms; cap the converted WCHAR name.
* https://learn.microsoft.com/en-us/windows/win32/dataxchg/about-atom-tables */
#define WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS 255u
/* Bound the peer-provided UTF-8 scan separately from the converted Windows name. */
#define WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES (WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS * 4u)
/* Validates the remote descriptor array size after cItems has been read safely. */
static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count)
@@ -61,6 +69,25 @@ static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count)
return size >= descriptors_size;
}
static BOOL wf_cliprdr_bounded_strlen(const char *value, size_t max_len, size_t *len)
{
size_t i;
if (!value || !len)
return FALSE;
for (i = 0; i <= max_len; i++)
{
if (value[i] == '\0')
{
*len = i;
return TRUE;
}
}
return FALSE;
}
/**
* Clipboard Formats
*/
@@ -205,6 +232,7 @@ struct _CliprdrStream
FILEDESCRIPTORW m_Dsc;
void *m_pData;
UINT32 m_connID;
UINT32 m_streamId; // unique CLIPRDR streamId; avoids leaking a heap pointer
};
typedef struct _CliprdrStream CliprdrStream;
@@ -258,6 +286,9 @@ struct wf_clipboard
char *req_fdata;
HANDLE req_fevent;
BOOL req_f_received;
UINT32 req_f_conn_id_expected; // connID of the outstanding request
UINT32 req_f_stream_id_expected; // streamId of the outstanding request; responses for another are dropped
LONG req_f_stream_id_seq; // source of unique per-stream ids
size_t nFiles;
size_t file_array_size;
@@ -288,7 +319,7 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format);
static UINT cliprdr_send_data_request(UINT32 connID, wfClipboard *clipboard, UINT32 format);
static UINT cliprdr_send_lock(wfClipboard *clipboard);
static UINT cliprdr_send_unlock(wfClipboard *clipboard);
static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, const void *streamid,
static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, UINT32 streamId,
ULONG index, UINT32 flag, DWORD positionhigh,
DWORD positionlow, ULONG request);
@@ -371,7 +402,7 @@ static ULONG STDMETHODCALLTYPE CliprdrStream_Release(IStream *This)
static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULONG cb,
ULONG *pcbRead)
{
int ret;
UINT ret;
CliprdrStream *instance = (CliprdrStream *)This;
wfClipboard *clipboard;
@@ -384,12 +415,23 @@ static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULO
if (instance->m_lOffset.QuadPart >= instance->m_lSize.QuadPart)
return S_FALSE;
ret = cliprdr_send_request_filecontents(clipboard, instance->m_connID, (void *)This, instance->m_lIndex,
ret = cliprdr_send_request_filecontents(clipboard, instance->m_connID, instance->m_streamId, instance->m_lIndex,
FILECONTENTS_RANGE, instance->m_lOffset.HighPart,
instance->m_lOffset.LowPart, cb);
if (ret < 0)
if (ret != CHANNEL_RC_OK)
{
free(clipboard->req_fdata);
clipboard->req_fdata = NULL;
return E_FAIL;
}
if (clipboard->req_fsize > cb)
{
free(clipboard->req_fdata);
clipboard->req_fdata = NULL;
return STG_E_READFAULT;
}
if (clipboard->req_fdata)
{
@@ -601,6 +643,7 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData,
instance->m_pData = pData;
instance->m_lOffset.QuadPart = 0;
instance->m_connID = connID;
instance->m_streamId = (UINT32)InterlockedIncrement(&clipboard->req_f_stream_id_seq);
if (instance->m_Dsc.dwFlags & FD_ATTRIBUTES)
{
@@ -611,16 +654,28 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData,
if (((instance->m_Dsc.dwFlags & FD_FILESIZE) == 0) && !isDir)
{
/* get content size of this stream */
if (cliprdr_send_request_filecontents(clipboard, instance->m_connID, (void *)instance,
if (cliprdr_send_request_filecontents(clipboard, instance->m_connID, instance->m_streamId,
instance->m_lIndex, FILECONTENTS_SIZE, 0, 0,
8) == CHANNEL_RC_OK)
{
success = TRUE;
}
if (clipboard->req_fdata != NULL)
if (clipboard->req_fdata != NULL && clipboard->req_fsize >= sizeof(LONGLONG))
{
LONGLONG sz = 0;
CopyMemory(&sz, clipboard->req_fdata, sizeof(sz));
if (sz < 0)
success = FALSE;
else
instance->m_lSize.QuadPart = sz;
}
else
{
success = FALSE;
}
if (clipboard->req_fdata)
{
instance->m_lSize.QuadPart = *((LONGLONG *)clipboard->req_fdata);
free(clipboard->req_fdata);
clipboard->req_fdata = NULL;
}
@@ -1406,25 +1461,35 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format)
return local_format;
}
static void map_ensure_capacity(wfClipboard *clipboard)
static BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity)
{
size_t old_size;
formatMapping *new_map;
if (!clipboard)
return;
return FALSE;
if (clipboard->map_size >= clipboard->map_capacity)
{
size_t new_size;
formatMapping *new_map;
new_size = clipboard->map_capacity * 2;
new_map =
(formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * new_size);
if (!clipboard->format_mappings)
return FALSE;
if (!new_map)
return;
if (capacity <= clipboard->map_capacity)
return TRUE;
clipboard->format_mappings = new_map;
clipboard->map_capacity = new_size;
}
if (capacity > WF_CLIPRDR_MAX_FORMATS ||
capacity > ((size_t)-1) / sizeof(formatMapping))
return FALSE;
old_size = clipboard->map_capacity;
new_map =
(formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * capacity);
if (!new_map)
return FALSE;
memset(new_map + old_size, 0, sizeof(formatMapping) * (capacity - old_size));
clipboard->format_mappings = new_map;
clipboard->map_capacity = capacity;
return TRUE;
}
static BOOL clear_format_map(wfClipboard *clipboard)
@@ -1451,6 +1516,13 @@ static BOOL clear_format_map(wfClipboard *clipboard)
return TRUE;
}
static UINT wf_cliprdr_server_format_list_fail(wfClipboard *clipboard)
{
clear_format_map(clipboard);
clipboard->copied = FALSE;
return ERROR_INTERNAL_ERROR;
}
static UINT cliprdr_send_tempdir(wfClipboard *clipboard)
{
CLIPRDR_TEMP_DIRECTORY tempDirectory;
@@ -1729,12 +1801,12 @@ static UINT cliprdr_send_data_request(UINT32 connID, wfClipboard *clipboard, UIN
return wait_response_event(connID, clipboard, clipboard->formatDataRespEvent, &clipboard->formatDataRespReceived, &clipboard->hmem);
}
UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, const void *streamid, ULONG index,
static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, UINT32 streamId, ULONG index,
UINT32 flag, DWORD positionhigh, DWORD positionlow,
ULONG nreq)
{
UINT rc;
CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest;
CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest = { 0 };
if (!clipboard || !clipboard->context || !clipboard->context->ClientFileContentsRequest)
return ERROR_INTERNAL_ERROR;
@@ -1745,12 +1817,11 @@ UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, co
return rc;
}
clipboard->req_f_received = FALSE;
clipboard->req_f_conn_id_expected = connID;
clipboard->req_f_stream_id_expected = streamId;
fileContentsRequest.connID = connID;
// streamId is `IStream*` pointer, though it is not very good on a 64-bit system.
// But it is OK, because it is only used to check if the stream is the same in
// `wf_cliprdr_server_file_contents_request()` function.
fileContentsRequest.streamId = (UINT32)(ULONG_PTR)streamid;
fileContentsRequest.streamId = streamId;
fileContentsRequest.listIndex = index;
fileContentsRequest.dwFlags = flag;
fileContentsRequest.nPositionLow = positionlow;
@@ -2443,6 +2514,16 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
if (!clear_format_map(clipboard))
return ERROR_INTERNAL_ERROR;
clipboard->copied = FALSE;
if (formatList->numFormats > WF_CLIPRDR_MAX_FORMATS)
return ERROR_INTERNAL_ERROR;
if (formatList->numFormats > 0 && !formatList->formats)
return ERROR_INTERNAL_ERROR;
if (!map_ensure_capacity(clipboard, formatList->numFormats))
return ERROR_INTERNAL_ERROR;
clipboard->copied = TRUE;
@@ -2450,19 +2531,58 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
{
format = &(formatList->formats[i]);
mapping = &(clipboard->format_mappings[i]);
/* Do not validate the peer-provided formatId as a Windows registered format.
* It is only a remote protocol ID used when requesting data from the peer.
* For named formats, RegisterClipboardFormatW creates the local Windows
* clipboard ID below, and that local ID is checked before publishing. */
mapping->remote_format_id = format->formatId;
if (format->formatName)
{
int size = MultiByteToWideChar(CP_UTF8, 0, format->formatName,
strlen(format->formatName), NULL, 0);
mapping->name = calloc(size + 1, sizeof(WCHAR));
size_t name_len;
int size;
if (mapping->name)
if (!wf_cliprdr_bounded_strlen(format->formatName,
WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES, &name_len))
{
MultiByteToWideChar(CP_UTF8, 0, format->formatName, strlen(format->formatName),
mapping->name, size);
mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name);
return wf_cliprdr_server_format_list_fail(clipboard);
}
if (name_len == 0)
{
return wf_cliprdr_server_format_list_fail(clipboard);
}
size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len,
NULL, 0);
if (size <= 0)
{
return wf_cliprdr_server_format_list_fail(clipboard);
}
if ((UINT)size > WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS)
{
return wf_cliprdr_server_format_list_fail(clipboard);
}
mapping->name = calloc((size_t)size + 1, sizeof(WCHAR));
if (!mapping->name)
{
return wf_cliprdr_server_format_list_fail(clipboard);
}
if (MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len,
mapping->name, size) != size)
{
free(mapping->name);
mapping->name = NULL;
return wf_cliprdr_server_format_list_fail(clipboard);
}
mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name);
if (mapping->local_format_id == 0)
{
return wf_cliprdr_server_format_list_fail(clipboard);
}
}
else
@@ -2472,7 +2592,6 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
}
clipboard->map_size++;
map_ensure_capacity(clipboard);
}
if (file_transferring(clipboard))
@@ -2923,6 +3042,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
BOOL bIsStreamFile = TRUE;
static LPSTREAM pStreamStc = NULL;
static UINT32 uStreamIdStc = 0;
static UINT32 uConnIdStc = 0;
wfClipboard *clipboard;
UINT rc = ERROR_INTERNAL_ERROR;
UINT sRc;
@@ -2996,7 +3116,8 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
vFormatEtc.lindex = fileContentsRequest->listIndex;
vFormatEtc.ptd = NULL;
if ((uStreamIdStc != fileContentsRequest->streamId) || !pStreamStc)
if ((uStreamIdStc != fileContentsRequest->streamId) ||
(uConnIdStc != fileContentsRequest->connID) || !pStreamStc)
{
LPENUMFORMATETC pEnumFormatEtc;
ULONG CeltFetched;
@@ -3027,6 +3148,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
{
pStreamStc = vStgMedium.pstm;
uStreamIdStc = fileContentsRequest->streamId;
uConnIdStc = fileContentsRequest->connID;
bIsStreamFile = TRUE;
}
@@ -3190,6 +3312,9 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context,
rc = ERROR_INTERNAL_ERROR;
break;
}
if (fileContentsResponse->connID != clipboard->req_f_conn_id_expected ||
fileContentsResponse->streamId != clipboard->req_f_stream_id_expected)
return CHANNEL_RC_OK;
clipboard->req_fsize = 0;
clipboard->req_fdata = NULL;
@@ -3200,6 +3325,13 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context,
}
clipboard->req_fsize = fileContentsResponse->cbRequested;
/*
* Keep the zero-size allocation: supported Windows builds use the Microsoft
* CRT, where malloc(0) returns a valid pointer. wait_response_event() also
* uses a non-NULL req_fdata to recognize a successful zero-byte response.
* The Rust FFI derives requestedData and cbRequested from the same Vec, so a
* nonzero length cannot have a NULL data pointer on the normal call path.
*/
clipboard->req_fdata = (char *)malloc(fileContentsResponse->cbRequested);
if (!clipboard->req_fdata)
{

View File

@@ -113,11 +113,11 @@ pub enum MouseButton {
/// Scroll up button
ScrollUp,
/// Left right button
/// Scroll down button
ScrollDown,
/// Left right button
/// Scroll left button
ScrollLeft,
/// Left right button
/// Scroll right button
ScrollRight,
}

View File

@@ -223,7 +223,7 @@ impl KeyboardControllable for Enigo {
// Windows uses uft-16 encoding. We need to check
// for variable length characters. As such some
// characters can be 32 bit long and those are
// encoded in such called hight and low surrogates
// encoded in so-called high and low surrogates
// each 16 bit wide that needs to be send after
// another to the SendInput function without
// being interrupted by "keyup"

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk-portable-packer"
version = "1.4.7"
version = "1.4.9"
edition = "2021"
description = "RustDesk Remote Desktop"
@@ -26,7 +26,7 @@ windows = { version = "0.61", features = [
native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]}
[package.metadata.winres]
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
ProductName = "RustDesk"
OriginalFilename = "rustdesk.exe"
FileDescription = "RustDesk Remote Desktop"

View File

@@ -2,6 +2,7 @@
import os
import optparse
import subprocess
from hashlib import md5
import brotli
import datetime
@@ -65,11 +66,15 @@ def write_app_metadata(output_folder: str):
print(f"App metadata has been written to {output_path}")
def build_portable(output_folder: str, target: str):
os.chdir(output_folder)
if target:
os.system("cargo build --locked --release --target " + target)
else:
os.system("cargo build --locked --release")
current_dir = os.getcwd()
try:
os.chdir(output_folder)
cmd = ["cargo", "build", "--locked", "--release"]
if target:
cmd.extend(["--target", target])
subprocess.run(cmd, check=True)
finally:
os.chdir(current_dir)
# Linux: python3 generate.py -f ../rustdesk-portable-packer/test -o . -e ./test/main.py
# Windows: python3 .\generate.py -f ..\rustdesk\flutter\build\windows\runner\Debug\ -o . -e ..\rustdesk\flutter\build\windows\runner\Debug\rustdesk.exe

View File

@@ -48,7 +48,7 @@ quest = "0.3"
[build-dependencies]
target_build_utils = "0.3"
bindgen = "0.65"
bindgen = "0.72.1"
pkg-config = { version = "0.3.27", optional = true }
[target.'cfg(target_os = "linux")'.dependencies]

View File

@@ -4,19 +4,48 @@ use std::{
println,
};
/// The static library name may differ from the package name.
fn link_lib_name(package: &str) -> &str {
match package {
"svt-av1" => "SvtAv1Enc",
_ => package.trim_start_matches("lib"),
}
}
#[cfg(all(target_os = "linux", feature = "linux-pkg-config"))]
fn link_pkg_config(name: &str) -> Vec<PathBuf> {
// sometimes an override is needed
let pc_name = match name {
"libvpx" => "vpx",
"svt-av1" => "SvtAv1Enc",
_ => name,
};
let lib = pkg_config::probe_library(pc_name)
let mut pc = pkg_config::Config::new();
if name == "svt-av1" {
// svt_av1.rs requires the 4.x API (rtc, PredStructure, on-the-fly rate events)
pc.atleast_version("4.2");
}
let pkg_hint = match name {
"svt-av1" => "libsvt-av1-dev (needs svt-av1 >= 4.2)".to_owned(),
_ => format!("{pc_name}-dev"),
};
let lib = pc.probe(pc_name)
.expect(format!(
"unable to find '{pc_name}' development headers with pkg-config (feature linux-pkg-config is enabled).
try installing '{pc_name}-dev' from your system package manager.").as_str());
try installing '{pkg_hint}' from your system package manager.").as_str());
lib.include_paths
let mut include_paths = lib.include_paths;
// SvtAv1Enc.pc's Cflags is -I${includedir}/svt-av1, but svt_av1_ffi.h uses
// #include <svt-av1/...>, so the parent include root is needed too.
if name == "svt-av1" {
let parents: Vec<PathBuf> = include_paths
.iter()
.filter(|p| p.ends_with("svt-av1"))
.filter_map(|p| p.parent().map(|p| p.to_path_buf()))
.collect();
include_paths.extend(parents);
}
include_paths
}
#[cfg(not(all(target_os = "linux", feature = "linux-pkg-config")))]
fn link_pkg_config(_name: &str) -> Vec<PathBuf> {
@@ -47,7 +76,7 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf {
format!("{}-{}", target_arch, target_os)
}
} else if target_os == "windows" {
"x64-windows-static".to_owned()
format!("{}-windows-static", target_arch)
} else {
format!("{}-{}", target_arch, target_os)
};
@@ -61,10 +90,7 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf {
path.push("installed");
}
path.push(target);
println!(
"cargo:rustc-link-lib=static={}",
name.trim_start_matches("lib")
);
println!("cargo:rustc-link-lib=static={}", link_lib_name(name));
println!(
"cargo:rustc-link-search={}",
path.join("lib").to_str().unwrap()
@@ -103,11 +129,13 @@ fn link_homebrew_m1(name: &str) -> PathBuf {
);
}
path.push(directories.pop().unwrap());
// Link the library.
println!(
"cargo:rustc-link-lib=static={}",
name.trim_start_matches("lib")
);
// Link the library. Homebrew's svt-av1 bottle only ships a dylib.
if name == "svt-av1" {
println!("cargo:rustc-link-lib={}", link_lib_name(name));
println!("cargo:warning=svt-av1 is linked dynamically from homebrew, the binary needs homebrew's svt-av1 at runtime and is not relocatable; set VCPKG_ROOT for a static build");
} else {
println!("cargo:rustc-link-lib=static={}", link_lib_name(name));
}
// Add the library path.
println!(
"cargo:rustc-link-search={}",
@@ -120,8 +148,9 @@ fn link_homebrew_m1(name: &str) -> PathBuf {
}
/// Find package. By default, it will try to find vcpkg first, then homebrew(currently only for Mac M1).
/// If building for linux and feature "linux-pkg-config" is enabled, will try to use pkg-config
/// unless check fails (e.g. NO_PKG_CONFIG_libyuv=1)
/// If building on a linux host and feature "linux-pkg-config" is enabled, will try to use pkg-config
/// unless check fails (e.g. NO_PKG_CONFIG_libyuv=1). Note the cfg! below sees the build host, not
/// the target, so cross builds (e.g. linux -> android) must not enable linux-pkg-config.
fn find_package(name: &str) -> Vec<PathBuf> {
let no_pkg_config_var_name = format!("NO_PKG_CONFIG_{name}");
println!("cargo:rerun-if-env-changed={no_pkg_config_var_name}");
@@ -170,9 +199,9 @@ fn gen_vcpkg_package(package: &str, ffi_header: &str, generated: &str, regex: &s
let out_dir = Path::new(&out_dir);
let ffi_header = src_dir.join("src").join("bindings").join(ffi_header);
println!("rerun-if-changed={}", ffi_header.display());
println!("cargo:rerun-if-changed={}", ffi_header.display());
for dir in &includes {
println!("rerun-if-changed={}", dir.display());
println!("cargo:rerun-if-changed={}", dir.display());
}
let ffi_rs = out_dir.join(generated);
@@ -248,6 +277,18 @@ fn main() {
gen_vcpkg_package("libvpx", "vpx_ffi.h", "vpx_ffi.rs", "^[vV].*");
gen_vcpkg_package("aom", "aom_ffi.h", "aom_ffi.rs", "^(aom|AOM|OBU|AV1).*");
gen_vcpkg_package("libyuv", "yuv_ffi.h", "yuv_ffi.rs", ".*");
// Skipped on 32-bit targets: svt-av1 does not support them, the vcpkg
// manifest does not install it there and disable_av1() never uses AV1 on
// them anyway. Must stay in sync with the cfg gates in
// mod.rs/codec.rs/video_service.rs.
if env::var("CARGO_CFG_TARGET_POINTER_WIDTH").as_deref() == Ok("64") {
gen_vcpkg_package(
"svt-av1",
"svt_av1_ffi.h",
"svt_av1_ffi.rs",
"^(svt_av1_|Svt|SVT_|Eb|EB_|PredStructure|PrivDataType).*",
);
}
// ffmpeg();
if target_os == "ios" {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
#include <svt-av1/EbSvtAv1.h>
#include <svt-av1/EbSvtAv1Enc.h>
#include <svt-av1/EbSvtAv1ErrorCodes.h>
#include <svt-av1/EbSvtAv1Formats.h>
#include <svt-av1/EbSvtAv1Metadata.h>

View File

@@ -79,6 +79,10 @@ mod webrtc {
}
}
fn tile_log2(threads: u32) -> std::os::raw::c_uint {
(threads as f64).log2().ceil() as _
}
fn get_super_block_size(width: u32, height: u32, threads: u32) -> aom_superblock_size_t {
use aom_superblock_size::*;
let resolution = width * height;
@@ -160,8 +164,7 @@ mod webrtc {
} else {
AV1E_SET_TILE_COLUMNS
};
// Failed on android
call_ctl!(ctx, tile_set, (cfg.g_threads as f64 * 1.0f64).log2().ceil());
call_ctl!(ctx, tile_set, tile_log2(cfg.g_threads));
call_ctl!(ctx, AV1E_SET_ROW_MT, 1);
call_ctl!(ctx, AV1E_SET_ENABLE_OBMC, 0);
call_ctl!(ctx, AV1E_SET_NOISE_SENSITIVITY, 0);
@@ -197,6 +200,23 @@ mod webrtc {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::raw::c_uint;
#[test]
fn tile_log2_uses_c_uint_and_rounds_up() {
let one_thread: c_uint = tile_log2(1);
let three_threads: c_uint = tile_log2(3);
let max_threads: c_uint = tile_log2(64);
assert_eq!(one_thread, 0);
assert_eq!(three_threads, 2);
assert_eq!(max_threads, 6);
}
}
}
impl EncoderApi for AomEncoder {

View File

@@ -9,6 +9,8 @@ use std::{
use crate::hwcodec::*;
#[cfg(feature = "mediacodec")]
use crate::mediacodec::{MediaCodecDecoder, H264_DECODER_SUPPORT, H265_DECODER_SUPPORT};
#[cfg(target_pointer_width = "64")]
use crate::svt_av1::{SvtAv1Encoder, SvtAv1EncoderConfig};
#[cfg(feature = "vram")]
use crate::vram::*;
use crate::{
@@ -51,6 +53,8 @@ pub const ENCODE_NEED_SWITCH: &'static str = "ENCODE_NEED_SWITCH";
pub enum EncoderCfg {
VPX(VpxEncoderConfig),
AOM(AomEncoderConfig),
#[cfg(target_pointer_width = "64")]
SVTAV1(SvtAv1EncoderConfig),
#[cfg(feature = "hwcodec")]
HWRAM(HwRamEncoderConfig),
#[cfg(feature = "vram")]
@@ -71,6 +75,11 @@ pub trait EncoderApi {
fn set_quality(&mut self, ratio: f32) -> ResultType<()>;
/// Inform the encoder of the current pacing rate. Only rate controls that
/// budget per frame from a configured frame rate need to override the
/// default no-op.
fn set_fps(&mut self, _fps: u32) {}
fn bitrate(&self) -> u32;
fn support_changing_quality(&self) -> bool;
@@ -137,9 +146,34 @@ impl Encoder {
EncoderCfg::VPX(_) => Ok(Encoder {
codec: Box::new(VpxEncoder::new(config, i444)?),
}),
EncoderCfg::AOM(_) => Ok(Encoder {
codec: Box::new(AomEncoder::new(config, i444)?),
}),
EncoderCfg::AOM(_) => {
log::info!("AV1 encoder: aom");
Ok(Encoder {
codec: Box::new(AomEncoder::new(config, i444)?),
})
}
#[cfg(target_pointer_width = "64")]
EncoderCfg::SVTAV1(svt) => match SvtAv1Encoder::new(EncoderCfg::SVTAV1(svt), i444) {
Ok(codec) => {
log::info!("AV1 encoder: svt-av1");
Ok(Encoder {
codec: Box::new(codec),
})
}
Err(e) => {
// Same wire format, aom keeps the negotiated AV1 session alive.
log::error!("AV1 encoder: svt-av1 init failed ({e:?}), using aom");
let aom = EncoderCfg::AOM(AomEncoderConfig {
width: svt.width,
height: svt.height,
quality: svt.quality,
keyframe_interval: svt.keyframe_interval,
});
Ok(Encoder {
codec: Box::new(AomEncoder::new(aom, i444)?),
})
}
},
#[cfg(feature = "hwcodec")]
EncoderCfg::HWRAM(_) => match HwRamEncoder::new(config, i444) {
@@ -269,7 +303,10 @@ impl Encoder {
let preference = most_frequent.enum_value_or(PreferCodec::Auto);
// auto: h265 > h264 > av1/vp9/vp8
let av1_test = Config::get_option(hbb_common::config::keys::OPTION_AV1_TEST) != "N";
let av1_test = {
let v = Config::get_option(hbb_common::config::keys::OPTION_AV1_TEST);
v != "N" && v != "SVT-N"
};
let mut auto_codec = if av1_useable && av1_test {
CodecFormat::AV1
} else {
@@ -365,6 +402,8 @@ impl Encoder {
VpxVideoCodecId::VP9 => CodecFormat::VP9,
},
EncoderCfg::AOM(_) => CodecFormat::AV1,
#[cfg(target_pointer_width = "64")]
EncoderCfg::SVTAV1(_) => CodecFormat::AV1,
#[cfg(feature = "hwcodec")]
EncoderCfg::HWRAM(hw) => {
let name = hw.name.to_lowercase();
@@ -411,6 +450,10 @@ impl Encoder {
VpxVideoCodecId::VP9 => decodings.iter().all(|d| d.1.i444.vp9),
},
EncoderCfg::AOM(_) => decodings.iter().all(|d| d.1.i444.av1),
// Mirrors AOM so an i444 preference change still triggers the encoder
// rebuild, the rebuild then selects aom for the actual i444 encoding.
#[cfg(target_pointer_width = "64")]
EncoderCfg::SVTAV1(_) => decodings.iter().all(|d| d.1.i444.av1),
#[cfg(feature = "hwcodec")]
EncoderCfg::HWRAM(_) => false,
#[cfg(feature = "vram")]
@@ -1042,7 +1085,15 @@ pub fn test_av1() {
use hbb_common::rand::Rng;
use std::{sync::Once, time::Duration};
if disable_av1() || !Config::get_option(OPTION_AV1_TEST).is_empty() {
// The verdict is tied to the encoder generation that produced it, so
// switching the AV1 encoder (aom <-> svt-av1) re-runs the test.
#[cfg(target_pointer_width = "64")]
const AV1_TEST_RESULT: (&str, &str) = ("SVT-Y", "SVT-N");
#[cfg(not(target_pointer_width = "64"))]
const AV1_TEST_RESULT: (&str, &str) = ("Y", "N");
let cached = Config::get_option(OPTION_AV1_TEST);
if disable_av1() || cached == AV1_TEST_RESULT.0 || cached == AV1_TEST_RESULT.1 {
log::info!("skip test av1");
return;
}
@@ -1101,15 +1152,40 @@ pub fn test_av1() {
}
Ok(dst)
};
let Ok(mut av1) = AomEncoder::new(
EncoderCfg::AOM(AomEncoderConfig {
let aom = || {
AomEncoder::new(
EncoderCfg::AOM(AomEncoderConfig {
width,
height,
quality,
keyframe_interval,
}),
i444,
)
.map(|e| Box::new(e) as Box<dyn EncoderApi>)
};
// Test the encoder actually used for AV1, svt-av1 on 64-bit targets, with
// the same fallback to aom as Encoder::new so an svt init failure
// does not brand a working aom as unusable. Note that i444 (true
// color) sessions and svt-unsupported resolutions still run aom,
// whose speed is then not covered by this gate.
#[cfg(target_pointer_width = "64")]
let av1 = crate::svt_av1::SvtAv1Encoder::new(
EncoderCfg::SVTAV1(crate::svt_av1::SvtAv1EncoderConfig {
width,
height,
quality,
keyframe_interval,
qp_range: None,
preset: None,
}),
i444,
) else {
)
.map(|e| Box::new(e) as Box<dyn EncoderApi>)
.or_else(|_| aom());
#[cfg(not(target_pointer_width = "64"))]
let av1 = aom();
let Ok(mut av1) = av1 else {
return false;
};
let mut key_frame_time = Duration::ZERO;
@@ -1122,7 +1198,7 @@ pub fn test_av1() {
};
let start = Instant::now();
if av1
.encode(pts.elapsed().as_millis() as _, &yuv, super::STRIDE_ALIGN)
.encode_to_message(EncodeInput::YUV(&yuv), pts.elapsed().as_millis() as _)
.is_err()
{
log::debug!("av1 encode failed");
@@ -1150,7 +1226,12 @@ pub fn test_av1() {
let v = f();
Config::set_option(
OPTION_AV1_TEST.to_string(),
if v { "Y" } else { "N" }.to_string(),
if v {
AV1_TEST_RESULT.0
} else {
AV1_TEST_RESULT.1
}
.to_string(),
);
});
});

View File

@@ -52,6 +52,8 @@ pub mod aom;
#[cfg(not(any(target_os = "ios")))]
pub mod camera;
pub mod record;
#[cfg(target_pointer_width = "64")]
pub mod svt_av1;
mod vpx;
#[repr(usize)]

View File

@@ -0,0 +1,536 @@
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(non_upper_case_globals)]
#![allow(improper_ctypes)]
#![allow(dead_code)]
include!(concat!(env!("OUT_DIR"), "/svt_av1_ffi.rs"));
use crate::codec::{base_bitrate, codec_thread_num, EncoderApi, EncoderCfg};
use crate::{EncodeInput, EncodeYuvFormat, Pixfmt, STRIDE_ALIGN};
use hbb_common::{
anyhow::Context,
bail,
bytes::Bytes,
log,
message_proto::{EncodedVideoFrame, EncodedVideoFrames, VideoFrame},
ResultType,
};
use std::{mem::MaybeUninit, os::raw::c_void, ptr, slice};
// SVT-AV1 rejects on-the-fly target bitrates above 100_000 kbps.
const MAX_TARGET_BITRATE_KBPS: u32 = 100_000;
// CBR budgets bits per frame from the configured frame rate, VideoQoS pushes
// its authoritative pacing rate in via set_fps.
const DEFAULT_FPS: u32 = 30;
#[derive(Clone, Copy, Debug)]
pub struct SvtAv1EncoderConfig {
pub width: u32,
pub height: u32,
pub quality: f32,
pub keyframe_interval: Option<usize>,
// SVT cannot update min/max QP on the fly. Product callers leave this
// unset so dynamic quality changes retain the full project QP envelope;
// controlled comparisons can pin the same range used by another encoder.
pub qp_range: Option<(u32, u32)>,
// RTC supports M7 through M13. Product callers use the screen-content
// default; benchmarks can override it to measure the speed/quality curve.
pub preset: Option<i8>,
}
pub struct SvtAv1Encoder {
handle: *mut EbComponentType,
width: usize,
height: usize,
yuvfmt: EncodeYuvFormat,
// current target bitrate in kbps, same unit as aom's rc_target_bitrate
bitrate: u32,
// bitrate change (kbps) to apply with the next picture via RATE_CHANGE_EVENT
pending_bitrate: Option<u32>,
// frame rate currently configured in the encoder
fps: u32,
// frame rate change to apply with the next picture via FRAME_RATE_CHANGE_EVENT
pending_fps: Option<u32>,
}
// The handle is only used behind &mut self, SVT-AV1 synchronizes internally.
unsafe impl Send for SvtAv1Encoder {}
impl EncoderApi for SvtAv1Encoder {
fn new(cfg: EncoderCfg, i444: bool) -> ResultType<Self>
where
Self: Sized,
{
match cfg {
EncoderCfg::SVTAV1(config) => {
if i444 {
// SVT-AV1 only supports 4:2:0 input, callers must fall back to aom for i444.
bail!("svt-av1 encoder does not support I444");
}
if !Self::support(config.width, config.height) {
bail!(
"svt-av1 encoder does not support resolution {}x{}",
config.width,
config.height
);
}
if let Some((min_qp, max_qp)) = config.qp_range {
if min_qp > max_qp || max_qp > 63 {
bail!("invalid svt-av1 QP range {min_qp}..{max_qp}");
}
}
if let Some(preset) = config.preset {
if !(7..=13).contains(&preset) {
bail!("invalid svt-av1 RTC preset M{preset}; expected M7..M13");
}
}
let mut handle: *mut EbComponentType = ptr::null_mut();
let mut c: MaybeUninit<EbSvtAv1EncConfiguration> = MaybeUninit::zeroed();
let res = unsafe { svt_av1_enc_init_handle(&mut handle, c.as_mut_ptr()) };
if res != EbErrorType::EB_ErrorNone || handle.is_null() {
bail!("svt_av1_enc_init_handle failed: {res:?}");
}
// c is loaded with the library defaults now, only override what we need.
let mut c = unsafe { c.assume_init() };
let bitrate = Self::bitrate(config.width, config.height, config.quality)
.min(MAX_TARGET_BITRATE_KBPS);
Self::apply_config(&mut c, &config, bitrate);
let mut res = unsafe { svt_av1_enc_set_parameter(handle, &mut c) };
if res == EbErrorType::EB_ErrorNone {
res = unsafe { svt_av1_enc_init(handle) };
}
if res != EbErrorType::EB_ErrorNone {
unsafe {
svt_av1_enc_deinit_handle(handle);
}
bail!("failed to init svt-av1 encoder: {res:?}");
}
Ok(Self {
handle,
width: config.width as _,
height: config.height as _,
yuvfmt: Self::get_yuvfmt(config.width, config.height),
bitrate,
pending_bitrate: None,
fps: DEFAULT_FPS,
pending_fps: None,
})
}
_ => bail!("encoder type mismatch"),
}
}
fn encode_to_message(&mut self, input: EncodeInput, ms: i64) -> ResultType<VideoFrame> {
let frames = self
.encode(ms, input.yuv()?)
.with_context(|| "Failed to encode")?;
if frames.len() > 0 {
Ok(Self::create_video_frame(frames))
} else {
bail!("no valid frame");
}
}
fn yuvfmt(&self) -> EncodeYuvFormat {
self.yuvfmt.clone()
}
#[cfg(feature = "vram")]
fn input_texture(&self) -> bool {
false
}
fn set_quality(&mut self, ratio: f32) -> ResultType<()> {
let bitrate =
Self::bitrate(self.width as _, self.height as _, ratio).min(MAX_TARGET_BITRATE_KBPS);
if bitrate > 0 && bitrate != self.bitrate {
self.bitrate = bitrate;
self.pending_bitrate = Some(bitrate);
}
Ok(())
}
fn set_fps(&mut self, fps: u32) {
if fps == 0 {
return;
}
// also cancels a queued change that a later call made moot again
if fps == self.fps {
self.pending_fps = None;
} else {
self.pending_fps = Some(fps);
}
}
fn bitrate(&self) -> u32 {
self.bitrate
}
fn support_changing_quality(&self) -> bool {
true
}
fn latency_free(&self) -> bool {
true
}
fn is_hardware(&self) -> bool {
false
}
fn disable(&self) {}
}
impl SvtAv1Encoder {
pub fn support(width: u32, height: u32) -> bool {
width >= 64
&& height >= 64
&& width <= 16384
&& height <= 8704
&& width % 2 == 0
&& height % 2 == 0
}
fn apply_config(c: &mut EbSvtAv1EncConfiguration, cfg: &SvtAv1EncoderConfig, bitrate: u32) {
c.enc_mode = cfg
.preset
.unwrap_or_else(|| Self::preset(cfg.width, cfg.height));
c.source_width = cfg.width;
c.source_height = cfg.height;
// CBR budgets bits per frame from this rate, set_fps adjusts it on the
// fly with FRAME_RATE_CHANGE_EVENT.
c.frame_rate_numerator = DEFAULT_FPS;
c.frame_rate_denominator = 1;
c.encoder_bit_depth = 8;
c.encoder_color_format = EbColorFormat::EB_YUV420;
c.profile = EbAv1SeqProfile::MAIN_PROFILE;
// Low delay + rtc + CBR: one packet out per picture in, svt_av1_enc_get_packet
// blocks until the packet for the sent picture is ready, and rate/keyframe
// changes on the fly are allowed.
c.pred_structure = PredStructure::LOW_DELAY;
// A four-picture temporal hierarchy improves reference efficiency without
// introducing B-frame reordering. Two is also the highest hierarchy SVT
// validates for low-delay CBR, and still preserves one packet per input.
c.hierarchical_levels = 2;
c.rtc = true;
c.rate_control_mode = SvtAv1RcMode::SVT_AV1_RC_MODE_CBR as _;
c.target_bit_rate = bitrate.min(MAX_TARGET_BITRATE_KBPS) * 1000;
let initial_qp_range = Self::quality_qp_range(cfg.quality);
let (min_qp, max_qp) = cfg.qp_range.unwrap_or((5, 45));
c.min_qp_allowed = min_qp;
c.max_qp_allowed = max_qp;
c.qp = ((initial_qp_range.0 + initial_qp_range.1) / 2).clamp(min_qp, max_qp);
c.look_ahead_distance = 0;
// SVT 4.2's RTC CBR path only re-runs mode decision once when an inter
// frame would overflow the virtual buffer. This keeps network bursts
// bounded without enabling unrestricted multi-pass re-encoding.
c.recode_loop = 1; // ALLOW_RECODE_KFMAXBW / one RTC VBV recode
c.scene_change_detection = 0;
// Temporal filtering is primarily useful for camera/video content. It
// costs CPU and can soften text, while low-delay screen sharing cannot
// benefit from future-frame filtering.
c.enable_tf = 0;
c.enable_tf_key = false;
c.enable_overlays = false;
// Use SVT's anti-alias-aware adaptive screen detection: UI/text can use
// Palette and IntraBC while camera/video regions stay on the natural-
// content path. RTC only supports these tools at M8 or slower.
c.screen_content_mode = 3;
c.enable_intrabc = true;
c.tune = 1; // PSNR, low delay does not support tune 0
c.level_of_parallelism = Self::parallelism();
c.intra_refresh_type = SvtAv1IntraRefreshType::SVT_AV1_KF_REFRESH; // closed GOP
c.intra_period_length = match cfg.keyframe_interval {
Some(keyframe_interval) => keyframe_interval.saturating_sub(1) as _,
None => -1, // no periodic intra refresh, keyframes only on demand
};
// pic_type is left to the encoder and keyframes come from encoder
// restarts, the force_key_frames flag must stay off for low delay CBR
// (the library resets it with a warning).
c.force_key_frames = false;
}
fn preset(_width: u32, _height: u32) -> i8 {
// SVT-AV1 forces screen_content_mode to 0 for RTC presets M9 and faster,
// so use M8 to keep the screen-content path enabled.
// TODO: After benchmarking, select a quality-dependent preset in M0..=M8.
8
}
fn parallelism() -> u32 {
// level_of_parallelism is a level from 1 to 6, not a thread count.
match codec_thread_num(64) {
n if n >= 32 => 6,
n if n >= 16 => 5,
n if n >= 8 => 4,
n if n >= 4 => 3,
n if n >= 2 => 2,
_ => 1,
}
}
fn encode(&mut self, ms: i64, data: &[u8]) -> ResultType<Vec<EncodedVideoFrame>> {
let fmt = &self.yuvfmt;
let chroma_height = (fmt.h + 1) / 2;
let len = fmt.v + fmt.stride[2] * chroma_height;
if data.len() < len {
bail!("len not enough: {} < {}", data.len(), len);
}
let mut io = EbSvtIOFormat {
luma: data.as_ptr() as *mut u8,
cb: data[fmt.u..].as_ptr() as *mut u8,
cr: data[fmt.v..].as_ptr() as *mut u8,
y_stride: fmt.stride[0] as _,
cr_stride: fmt.stride[2] as _,
cb_stride: fmt.stride[1] as _,
};
let mut hdr: EbBufferHeaderType = unsafe { std::mem::zeroed() };
hdr.size = std::mem::size_of::<EbBufferHeaderType>() as _;
hdr.p_buffer = &mut io as *mut EbSvtIOFormat as *mut u8;
hdr.n_filled_len = len as _;
hdr.pts = ms;
hdr.pic_type = EbAv1PictureType::EB_AV1_INVALID_PICTURE; // encoder decides
// send_picture copies the private data list, stack lifetime is fine.
// The pending changes are only cleared after a successful send, so a
// failed send retries them with the next picture.
let pending_bitrate = self.pending_bitrate;
let pending_fps = self.pending_fps;
let mut rate_info = SvtAv1RateInfo {
seq_qp: 0,
target_bit_rate: pending_bitrate.unwrap_or(0).min(MAX_TARGET_BITRATE_KBPS) * 1000,
};
let mut fps_info = SvtAv1FrameRateInfo {
frame_rate_numerator: pending_fps.unwrap_or(0),
frame_rate_denominator: 1,
};
let mut rate_node = EbPrivDataNode {
node_type: PrivDataType::RATE_CHANGE_EVENT,
data: &mut rate_info as *mut SvtAv1RateInfo as *mut c_void,
size: std::mem::size_of::<SvtAv1RateInfo>() as _,
next: ptr::null_mut(),
};
let mut fps_node = EbPrivDataNode {
node_type: PrivDataType::FRAME_RATE_CHANGE_EVENT,
data: &mut fps_info as *mut SvtAv1FrameRateInfo as *mut c_void,
size: std::mem::size_of::<SvtAv1FrameRateInfo>() as _,
next: ptr::null_mut(),
};
let mut list: *mut EbPrivDataNode = ptr::null_mut();
if pending_bitrate.is_some() {
list = &mut rate_node;
}
if pending_fps.is_some() {
fps_node.next = list;
list = &mut fps_node;
}
hdr.p_app_private = list as *mut c_void;
// The input YUV planes are copied inside send_picture.
let res = unsafe { svt_av1_enc_send_picture(self.handle, &mut hdr) };
if res != EbErrorType::EB_ErrorNone {
bail!("svt_av1_enc_send_picture failed: {res:?}");
}
self.pending_bitrate = None;
self.pending_fps = None;
if let Some(fps) = pending_fps {
self.fps = fps;
}
// In low delay mode get_packet blocks until the packet for the picture just
// sent is ready, and each picture produces exactly one packet. Do not call
// it again without sending another picture, it would block forever.
let mut frames = Vec::new();
let mut pkt: *mut EbBufferHeaderType = ptr::null_mut();
let res = unsafe { svt_av1_enc_get_packet(self.handle, &mut pkt, 0) };
match res {
EbErrorType::EB_ErrorNone if !pkt.is_null() => unsafe {
let h = &*pkt;
frames.push(EncodedVideoFrame {
data: Bytes::from(
slice::from_raw_parts(h.p_buffer, h.n_filled_len as usize).to_vec(),
),
key: h.pic_type == EbAv1PictureType::EB_AV1_KEY_PICTURE,
pts: h.pts,
..Default::default()
});
svt_av1_enc_release_out_buffer(&mut pkt);
},
EbErrorType::EB_NoErrorEmptyQueue => {}
_ => {
if !pkt.is_null() {
unsafe {
svt_av1_enc_release_out_buffer(&mut pkt);
}
}
bail!("svt_av1_enc_get_packet failed: {res:?}");
}
}
Ok(frames)
}
#[inline]
fn create_video_frame(frames: Vec<EncodedVideoFrame>) -> VideoFrame {
let mut vf = VideoFrame::new();
let av1s = EncodedVideoFrames {
frames: frames.into(),
..Default::default()
};
vf.set_av1s(av1s);
vf
}
fn bitrate(width: u32, height: u32, ratio: f32) -> u32 {
let bitrate = base_bitrate(width, height) as f32;
(bitrate * ratio) as u32
}
// Same mapping as AomEncoder::calc_q_values.
#[inline]
pub fn quality_qp_range(ratio: f32) -> (u32, u32) {
let b = (ratio * 100.0) as u32;
let b = std::cmp::min(b, 200);
let q_min1 = 24;
let q_min2 = 5;
let q_max1 = 45;
let q_max2 = 25;
let t = b as f32 / 200.0;
let mut q_min: u32 = ((1.0 - t) * q_min1 as f32 + t * q_min2 as f32).round() as u32;
let mut q_max = ((1.0 - t) * q_max1 as f32 + t * q_max2 as f32).round() as u32;
q_min = q_min.clamp(q_min2, q_min1);
q_max = q_max.clamp(q_max2, q_max1);
(q_min, q_max)
}
fn get_yuvfmt(width: u32, height: u32) -> EncodeYuvFormat {
let w = width as usize;
let h = height as usize;
let align = |x: usize| (x + STRIDE_ALIGN - 1) & !(STRIDE_ALIGN - 1);
let stride_y = align(w);
let stride_uv = align((w + 1) / 2);
let u = stride_y * h;
let v = u + stride_uv * ((h + 1) / 2);
EncodeYuvFormat {
pixfmt: Pixfmt::I420,
w,
h,
stride: vec![stride_y, stride_uv, stride_uv],
u,
v,
}
}
}
impl Drop for SvtAv1Encoder {
fn drop(&mut self) {
unsafe {
if self.handle.is_null() {
return;
}
// svt_av1_enc_deinit drains the pipeline itself, but logs an error
// when EOS was not sent first.
let mut hdr: EbBufferHeaderType = std::mem::zeroed();
hdr.size = std::mem::size_of::<EbBufferHeaderType>() as _;
hdr.pic_type = EbAv1PictureType::EB_AV1_INVALID_PICTURE;
hdr.flags = EB_BUFFERFLAG_EOS;
let _ = svt_av1_enc_send_picture(self.handle, &mut hdr);
let res = svt_av1_enc_deinit(self.handle);
if res != EbErrorType::EB_ErrorNone {
log::error!("svt_av1_enc_deinit failed: {res:?}");
}
let res = svt_av1_enc_deinit_handle(self.handle);
if res != EbErrorType::EB_ErrorNone {
log::error!("svt_av1_enc_deinit_handle failed: {res:?}");
}
self.handle = ptr::null_mut();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::GoogleImage;
fn fake_i420(fmt: &EncodeYuvFormat, index: usize) -> Vec<u8> {
let chroma_height = (fmt.h + 1) / 2;
let len = fmt.v + fmt.stride[2] * chroma_height;
let mut yuv = vec![128u8; len];
// moving gradient so every frame differs
for y in 0..fmt.h {
let row = &mut yuv[y * fmt.stride[0]..y * fmt.stride[0] + fmt.w];
for (x, px) in row.iter_mut().enumerate() {
*px = ((x + y + index * 17) % 256) as u8;
}
}
yuv
}
#[test]
fn test_encode_and_decode_with_aom() {
let (width, height) = (640u32, 480u32);
let mut enc = SvtAv1Encoder::new(
crate::codec::EncoderCfg::SVTAV1(SvtAv1EncoderConfig {
width,
height,
quality: 1.0,
keyframe_interval: None,
qp_range: None,
preset: None,
}),
false,
)
.unwrap();
let fmt = enc.yuvfmt();
let mut dec = crate::aom::AomDecoder::new().unwrap();
for i in 0..20usize {
if i == 5 {
// exercises the FRAME_RATE_CHANGE_EVENT path on the next frame
enc.set_fps(60);
}
if i == 10 {
// exercises the RATE_CHANGE_EVENT path on the next frame
enc.set_quality(0.5).unwrap();
}
let yuv = fake_i420(&fmt, i);
let frames = enc.encode(i as i64 * 33, &yuv).unwrap();
// one packet out per picture in, this is the low delay contract
assert_eq!(frames.len(), 1, "no packet for frame {i}");
assert_eq!(frames[0].key, i == 0, "unexpected key flag for frame {i}");
assert!(!frames[0].data.is_empty());
let mut decoded = 0;
for f in &frames {
for img in dec.decode(&f.data).unwrap() {
assert_eq!(img.width(), width as usize);
assert_eq!(img.height(), height as usize);
decoded += 1;
}
}
for img in dec.flush().unwrap() {
assert_eq!(img.width(), width as usize);
decoded += 1;
}
assert!(decoded >= 1, "aom failed to decode svt-av1 frame {}", i);
}
}
#[test]
fn test_unsupported_resolution() {
assert!(SvtAv1Encoder::new(
crate::codec::EncoderCfg::SVTAV1(SvtAv1EncoderConfig {
width: 62,
height: 62,
quality: 1.0,
keyframe_interval: None,
qp_range: None,
preset: None,
}),
false,
)
.is_err());
}
}

View File

@@ -52,6 +52,33 @@ lazy_static::lazy_static! {
static ref MAG_BUFFER: Mutex<(bool, Vec<u8>)> = Default::default();
}
fn find_windows(cls: &str, name: &str) -> Result<Vec<HWND>> {
let name_c = CString::new(name)?;
let cls_c = if cls.is_empty() {
None
} else {
Some(CString::new(cls)?)
};
let mut hwnds = Vec::new();
unsafe {
let mut after = NULL as _;
loop {
let hwnd = FindWindowExA(
NULL as _,
after,
cls_c.as_ref().map_or(NULL as _, |c| c.as_ptr()),
name_c.as_ptr(),
);
if hwnd.is_null() {
break;
}
hwnds.push(hwnd);
after = hwnd;
}
}
Ok(hwnds)
}
pub type REFWICPixelFormatGUID = *const GUID;
pub type WICPixelFormatGUID = GUID;
@@ -247,6 +274,8 @@ pub struct CapturerMag {
rect: RECT,
width: usize,
height: usize,
excluded_window_target: Option<(String, String)>,
excluded_windows: Vec<HWND>,
}
impl Drop for CapturerMag {
@@ -261,6 +290,10 @@ impl CapturerMag {
MagInterface::new().is_ok()
}
// This captures through the legacy Windows Magnification API. Do not infer
// multi-monitor capture support from privacy overlay coverage: WebRTC also
// disables its magnifier capturer when SM_CMONITORS != 1.
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
pub(crate) fn new(origin: (i32, i32), width: usize, height: usize) -> Result<Self> {
unsafe {
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
@@ -305,6 +338,8 @@ impl CapturerMag {
},
width,
height,
excluded_window_target: None,
excluded_windows: Vec::new(),
};
unsafe {
@@ -436,19 +471,41 @@ impl CapturerMag {
}
pub(crate) fn exclude(&mut self, cls: &str, name: &str) -> Result<bool> {
let name_c = CString::new(name)?;
let mut hwnds = find_windows(cls, name)?;
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
self.excluded_window_target = Some((cls.to_owned(), name.to_owned()));
if hwnds.is_empty() {
self.excluded_windows.clear();
return Ok(false);
}
self.exclude_windows(&mut hwnds)?;
self.excluded_windows = hwnds;
Ok(true)
}
fn refresh_excluded_windows(&mut self) -> Result<()> {
let Some((cls, name)) = self.excluded_window_target.as_ref() else {
return Ok(());
};
let mut hwnds = find_windows(cls, name)?;
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
// This runs from frame() because refreshed privacy overlays get new
// HWNDs. It is only used on the legacy magnifier backend while privacy
// mode is active; if it shows up as hot-path cost, throttle this check.
// Keep the previous filter list while privacy windows are being recreated.
if hwnds.is_empty() || hwnds == self.excluded_windows {
return Ok(());
}
self.exclude_windows(&mut hwnds)?;
self.excluded_windows = hwnds;
Ok(())
}
fn exclude_windows(&mut self, hwnds: &mut [HWND]) -> Result<bool> {
let count = hwnds.len() as _;
unsafe {
let mut hwnd = if cls.len() == 0 {
FindWindowExA(NULL as _, NULL as _, NULL as _, name_c.as_ptr())
} else {
let cls_c = CString::new(cls).unwrap();
FindWindowExA(NULL as _, NULL as _, cls_c.as_ptr(), name_c.as_ptr())
};
if hwnd.is_null() {
return Ok(false);
}
if let Some(set_window_filter_list_func) =
self.mag_interface.set_window_filter_list_func
{
@@ -456,16 +513,15 @@ impl CapturerMag {
== set_window_filter_list_func(
self.magnifier_window,
MW_FILTERMODE_EXCLUDE,
1,
&mut hwnd,
count,
hwnds.as_mut_ptr(),
)
{
return Err(Error::new(
ErrorKind::Other,
format!(
"Failed MagSetWindowFilterList for cls {} name {}, error {}",
cls,
name,
"Failed MagSetWindowFilterList for {} windows, error {}",
count,
Error::last_os_error()
),
));
@@ -496,6 +552,7 @@ impl CapturerMag {
}
pub(crate) fn frame(&mut self, data: &mut Vec<u8>) -> Result<()> {
self.refresh_excluded_windows()?;
Self::clear_data();
unsafe {

View File

@@ -1,5 +1,5 @@
pkgname=rustdesk
pkgver=1.4.7
pkgver=1.4.9
pkgrel=0
epoch=
pkgdesc=""

View File

@@ -1,3 +1,3 @@
#! /usr/bin/env bash
sed -i "s/$1/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
sed -i "s/\b$1\b/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
cargo run # to bump version in cargo lock

View File

@@ -7,6 +7,10 @@
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<Keyword>Win32Proj</Keyword>
@@ -22,6 +26,12 @@
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
@@ -30,6 +40,9 @@
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
@@ -53,6 +66,28 @@
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;EXAMPLECADLL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<PrecompiledHeader>Use</PrecompiledHeader>
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
</ClCompile>
<Link>
<AdditionalDependencies>msi.lib;version.lib;%(AdditionalDependencies)</AdditionalDependencies>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="Common.h" />
<ClInclude Include="framework.h" />
@@ -65,6 +100,7 @@
<ClCompile Include="FirewallRules.cpp" />
<ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">Create</PrecompiledHeader>
</ClCompile>
<ClCompile Include="ReadConfig.cpp" />
<ClCompile Include="RemotePrinter.cpp" />

View File

@@ -67,7 +67,7 @@
Some msi packages reset the `VersionNT` value to 1000 on Windows 10.
https://www.advancedinstaller.com/user-guide/qa-OS-dependent-install.html -->
<!-- Remote printer also works on Win8.1 in my test. -->
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT &gt;= 603 AND PRINTER = 1 OR PRINTER = &quot;Y&quot; OR PRINTER = &quot;y&quot;" />
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT &gt;= 603 AND (PRINTER = 1 OR PRINTER = &quot;Y&quot; OR PRINTER = &quot;y&quot;)" />
<Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT &gt;= 603" />
<!--Workaround of "fire:FirewallException". If Outbound="Yes" or Outbound="true", the following error occurs.-->

View File

@@ -4,17 +4,17 @@
<?include ..\Includes.wxi?>
<!--
Properties and related actions for specifying whether to install start menu/desktop shortcuts.
Properties and related actions for specifying whether to install shortcuts and the printer.
-->
<!-- These are the actual properties that get used in conditions to determine whether to
install start menu shortcuts, they are initialized with a default value to install shortcuts.
They should not be set directly from the command line or registry, instead the CREATE* properties
below should be set, then they will update these properties with their values only if set. -->
install start menu shortcuts or the printer. Shortcut properties default to install;
PRINTER defaults to not install. The CREATE* properties below update shortcut
properties from command line, bundle, or registry values. -->
<Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="STARTUPSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="PRINTER" Value="1" Secure="yes"></Property>
<Property Id="PRINTER" Secure="yes"></Property>
<!-- These properties get set from either the command line, bundle or registry value,
if set they update the properties above with their value. -->
@@ -77,7 +77,11 @@
<!-- If a command line value or registry value was set, update the main properties with the value -->
<SetProperty Id="STARTMENUSHORTCUTS" Value="" After="RestoreSavedStartMenuShortcutsValue" Sequence="first" Condition="CREATESTARTMENUSHORTCUTS AND NOT (CREATESTARTMENUSHORTCUTS = 1 OR CREATESTARTMENUSHORTCUTS = &quot;Y&quot; OR CREATESTARTMENUSHORTCUTS = &quot;y&quot;)" />
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = &quot;Y&quot; OR CREATEDESKTOPSHORTCUTS = &quot;y&quot;)" />
<SetProperty Id="PRINTER" Value="" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = &quot;Y&quot; OR INSTALLPRINTER = &quot;y&quot;)" />
<!-- PRINTER defaults to empty now, so a saved or command-line INSTALLPRINTER=1
must explicitly enable the main PRINTER property. Non-truthy INSTALLPRINTER
values still clear PRINTER so upgrades preserve an explicit disabled choice. -->
<SetProperty Action="SetPrinterValueEnabled" Id="PRINTER" Value="1" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER = 1 OR INSTALLPRINTER = &quot;Y&quot; OR INSTALLPRINTER = &quot;y&quot;" />
<SetProperty Action="SetPrinterValueDisabled" Id="PRINTER" Value="" After="SetPrinterValueEnabled" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = &quot;Y&quot; OR INSTALLPRINTER = &quot;y&quot;)" />
</Fragment>
</Wix>

View File

@@ -79,7 +79,7 @@ heading 1;}{\s2\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\li
\ab\af1 \ltrch\fcs0 \b\ul\cf2\lang1033\langfe2052\langnp1033\insrsid1917520
\par }\pard \ltrpar\s2\qj \li0\ri0\sb100\sa100\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\lin0\itap0\pararsid8979511 {\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \hich\af1\dbch\af31505\loch\f1
\hich\f1 This Privacy Policy (hereinafter the \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 Policy}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Tech Pte. Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0
\b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 us}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1
\hich\f1 or \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 we}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1
@@ -300,4 +300,4 @@ b060828e6f37ed1567914b284d262452282e3198720e274a939cd08a54f980ae38a38f56e422a3a6
\lsdpriority49 \lsdlocked0 List Table 4 Accent 5;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 5;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 5;
\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 List Table 2 Accent 6;\lsdpriority48 \lsdlocked0 List Table 3 Accent 6;\lsdpriority49 \lsdlocked0 List Table 4 Accent 6;
\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Mention;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}}
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}}

View File

@@ -3,7 +3,7 @@
<IncludeSearchPaths>
</IncludeSearchPaths>
<Configurations>Release</Configurations>
<Platforms>x64</Platforms>
<Platforms>x64;ARM64</Platforms>
</PropertyGroup>
<ItemGroup>
<Content Include="Includes.wxi" />

View File

@@ -10,12 +10,17 @@ EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Release|x64 = Release|x64
Release|ARM64 = Release|ARM64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.ActiveCfg = Release|x64
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.Build.0 = Release|x64
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.ActiveCfg = Release|ARM64
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.Build.0 = Release|ARM64
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.ActiveCfg = Release|x64
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.Build.0 = Release|x64
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.ActiveCfg = Release|ARM64
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.Build.0 = Release|ARM64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -85,7 +85,7 @@ def make_parser():
"-m",
"--manufacturer",
type=str,
default="PURSLANE",
default="Purslane Tech Pte. Ltd.",
help="The app manufacturer.",
)
return parser
@@ -499,7 +499,7 @@ def update_license_file(app_name):
license_content = f.read()
license_content = license_content.replace("website rustdesk.com and other ", "")
license_content = license_content.replace("RustDesk", app_name)
license_content = re.sub("Purslane Ltd", app_name, license_content, flags=re.IGNORECASE)
license_content = re.sub(r"Purslane(?: Tech Pte\.)? Ltd", app_name, license_content, flags=re.IGNORECASE)
with open(license_file, "w", encoding="utf-8") as f:
f.write(license_content)

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.7
Version: 1.4.9
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.7
Version: 1.4.9
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.7
Version: 1.4.9
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -45,7 +45,7 @@ pre_start()
return 0
}
# When loging out from the interactive shell, the execution sequence is:
# When logging out from the interactive shell, the execution sequence is:
#
# IF ~/.bash_logout exists THEN
# execute ~/.bash_logout

View File

@@ -0,0 +1,7 @@
set(VCPKG_TARGET_ARCHITECTURE arm)
set(VCPKG_CRT_LINKAGE dynamic)
set(VCPKG_LIBRARY_LINKAGE static)
set(VCPKG_CMAKE_SYSTEM_NAME Android)
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
set(VCPKG_MAKE_BUILD_TRIPLET "--host=armv7a-linux-androideabi")
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=armeabi-v7a -DANDROID_ARM_NEON=ON)

View File

@@ -0,0 +1,7 @@
set(VCPKG_TARGET_ARCHITECTURE arm64)
set(VCPKG_CRT_LINKAGE dynamic)
set(VCPKG_LIBRARY_LINKAGE static)
set(VCPKG_CMAKE_SYSTEM_NAME Android)
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-linux-android")
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=arm64-v8a)

View File

@@ -0,0 +1,7 @@
set(VCPKG_TARGET_ARCHITECTURE x64)
set(VCPKG_CRT_LINKAGE dynamic)
set(VCPKG_LIBRARY_LINKAGE static)
set(VCPKG_CMAKE_SYSTEM_NAME Android)
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
set(VCPKG_MAKE_BUILD_TRIPLET "--host=x86_64-linux-android")
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=x86_64)

View File

@@ -0,0 +1,7 @@
set(VCPKG_TARGET_ARCHITECTURE x86)
set(VCPKG_CRT_LINKAGE dynamic)
set(VCPKG_LIBRARY_LINKAGE static)
set(VCPKG_CMAKE_SYSTEM_NAME Android)
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
set(VCPKG_MAKE_BUILD_TRIPLET "--host=i686-linux-android")
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=x86)

View File

@@ -130,14 +130,18 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
--cc=cl \
--enable-gpl \
--enable-d3d11va \
--enable-cuda \
--enable-ffnvcodec \
--enable-hwaccel=h264_nvdec \
--enable-hwaccel=hevc_nvdec \
--enable-hwaccel=h264_d3d11va \
--enable-hwaccel=hevc_d3d11va \
--enable-hwaccel=h264_d3d11va2 \
--enable-hwaccel=hevc_d3d11va2 \
")
if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86" OR VCPKG_TARGET_ARCHITECTURE STREQUAL "x64")
string(APPEND OPTIONS "\
--enable-cuda \
--enable-ffnvcodec \
--enable-hwaccel=h264_nvdec \
--enable-hwaccel=hevc_nvdec \
--enable-amf \
--enable-encoder=h264_amf \
--enable-encoder=hevc_amf \
@@ -147,6 +151,7 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
--enable-encoder=h264_qsv \
--enable-encoder=hevc_qsv \
")
endif()
if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86")
set(LIB_MACHINE_ARG /machine:x86)
@@ -154,6 +159,9 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64")
set(LIB_MACHINE_ARG /machine:x64)
string(APPEND OPTIONS " --arch=x86_64")
elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64")
set(LIB_MACHINE_ARG /machine:arm64)
string(APPEND OPTIONS " --arch=aarch64 --enable-cross-compile")
else()
message(FATAL_ERROR "Unsupported target architecture")
endif()

View File

@@ -0,0 +1,10 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -194,5 +194,5 @@
#Clang support, required to build static with LTO
-if(CMAKE_C_COMPILER_ID MATCHES "Clang" AND UNIX AND NOT APPLE)
+if(FALSE)
find_program(LLVM_LD_EXE llvm-ld)
find_program(LLVM_AR_EXE llvm-ar)
find_program(LLVM_RANLIB_EXE llvm-ranlib)

View File

@@ -0,0 +1,14 @@
diff --git a/Source/Lib/ASM_AVX2/synonyms_avx2.h b/Source/Lib/ASM_AVX2/synonyms_avx2.h
--- a/Source/Lib/ASM_AVX2/synonyms_avx2.h
+++ b/Source/Lib/ASM_AVX2/synonyms_avx2.h
@@ -86,3 +86,9 @@
-static INLINE __m256i yy_unpacklo_epi128(const __m256i in0, const __m256i in1) {
+#if defined(_MSC_VER) && _MSC_VER >= 1950
+#define MAYBE_INLINE NOINLINE
+#else
+#define MAYBE_INLINE INLINE
+#endif
+
+static MAYBE_INLINE __m256i yy_unpacklo_epi128(const __m256i in0, const __m256i in1) {
return _mm256_inserti128_si256(in0, _mm256_castsi256_si128(in1), 1);
}

View File

@@ -0,0 +1,45 @@
vcpkg_from_gitlab(
GITLAB_URL https://gitlab.com
OUT_SOURCE_PATH SOURCE_PATH
REPO AOMediaCodec/SVT-AV1
REF v${VERSION}
SHA512 37df96559af179d28acae10cdff98c94ee2c4ee086b2446ab362b57be9adf566d6f2adc28faa30648798d9bc7d18eaeb2d0665e7292982652496b605342e1c4b
PATCHES
# upstream forces llvm-ld/llvm-ar/llvm-ranlib for clang static builds on
# non-Apple unix, which breaks toolchains without the llvm binutils
no-force-llvm.diff
# MSVC >= 1950 miscompiles the inlined yy_unpacklo_epi128
no-inline-yy_unpacklo_epi128.diff
)
if(VCPKG_TARGET_ARCHITECTURE MATCHES "^(x86|x64)$")
# NASM is required to build the x86/x64 assembly
vcpkg_find_acquire_program(NASM)
set(SIMD_OPTIONS -DCOMPILE_C_ONLY=OFF "-DCMAKE_ASM_NASM_COMPILER=${NASM}")
elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "^(arm64|arm64ec)$" AND NOT VCPKG_TARGET_IS_WINDOWS)
set(SIMD_OPTIONS -DCOMPILE_C_ONLY=OFF)
else()
set(SIMD_OPTIONS -DCOMPILE_C_ONLY=ON)
endif()
vcpkg_cmake_configure(
SOURCE_PATH "${SOURCE_PATH}"
OPTIONS
${SIMD_OPTIONS}
-DBUILD_APPS=OFF
-DBUILD_TESTING=OFF
-DREPRODUCIBLE_BUILDS=ON
# SVT-AV1 defaults LTO to ON for gcc>=9/clang>=12, which puts LTO
# bitcode into the static archive and breaks linking with a different
# compiler version
-DSVT_AV1_LTO=OFF
)
vcpkg_cmake_install()
vcpkg_cmake_config_fixup(PACKAGE_NAME SVT-AV1 CONFIG_PATH lib/cmake/SVT-AV1)
vcpkg_copy_pdbs()
vcpkg_fixup_pkgconfig()
file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include")
vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE.md" "${SOURCE_PATH}/PATENTS.md")

View File

@@ -0,0 +1,19 @@
{
"name": "svt-av1",
"version-semver": "4.2.0",
"port-version": 0,
"description": "Scalable Video Technology AV1 software video encoder library",
"homepage": "https://gitlab.com/AOMediaCodec/SVT-AV1",
"license": "BSD-3-Clause-Clear",
"supports": "!x86 & !arm32 & !uwp",
"dependencies": [
{
"name": "vcpkg-cmake",
"host": true
},
{
"name": "vcpkg-cmake-config",
"host": true
}
]
}

View File

@@ -1,199 +0,0 @@
use crate::client::*;
use async_trait::async_trait;
use hbb_common::{
config::PeerConfig,
config::READ_TIMEOUT,
futures::{SinkExt, StreamExt},
log,
message_proto::*,
protobuf::Message as _,
rendezvous_proto::ConnType,
tokio::{self, sync::mpsc},
Stream,
};
use std::sync::{Arc, RwLock};
#[derive(Clone)]
pub struct Session {
id: String,
lc: Arc<RwLock<LoginConfigHandler>>,
sender: mpsc::UnboundedSender<Data>,
password: String,
}
impl Session {
pub fn new(id: &str, sender: mpsc::UnboundedSender<Data>) -> Self {
let mut password = "".to_owned();
if PeerConfig::load(id).password.is_empty() {
match rpassword::prompt_password("Enter password: ") {
Ok(p) => password = p,
Err(e) => {
log::error!("Failed to read password: {:?}", e);
password = "".to_owned();
}
}
}
let session = Self {
id: id.to_owned(),
sender,
password,
lc: Default::default(),
};
session.lc.write().unwrap().initialize(
id.to_owned(),
ConnType::PORT_FORWARD,
None,
false,
None,
None,
);
session
}
}
#[async_trait]
impl Interface for Session {
fn get_login_config_handler(&self) -> Arc<RwLock<LoginConfigHandler>> {
return self.lc.clone();
}
fn msgbox(&self, msgtype: &str, title: &str, text: &str, link: &str) {
match msgtype {
"input-password" => {
self.sender
.send(Data::Login((self.password.clone(), true)))
.ok();
}
"re-input-password" => {
log::error!("{}: {}", title, text);
match rpassword::prompt_password("Enter password: ") {
Ok(password) => {
let login_data = Data::Login((password, true));
self.sender.send(login_data).ok();
}
Err(e) => {
log::error!("reinput password failed, {:?}", e);
}
}
}
msg if msg.contains("error") => {
log::error!("{}: {}: {}", msgtype, title, text);
}
_ => {
log::info!("{}: {}: {}", msgtype, title, text);
}
}
}
fn handle_login_error(&self, err: &str) -> bool {
handle_login_error(self.lc.clone(), err, self)
}
fn handle_peer_info(&self, pi: PeerInfo) {
self.lc.write().unwrap().handle_peer_info(&pi);
}
async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) {
log::info!(
"password={}",
hbb_common::password_security::temporary_password()
);
handle_hash(self.lc.clone(), &pass, hash, self, peer).await;
}
async fn handle_login_from_ui(
&self,
os_username: String,
os_password: String,
password: String,
remember: bool,
peer: &mut Stream,
) {
handle_login_from_ui(
self.lc.clone(),
os_username,
os_password,
password,
remember,
peer,
)
.await;
}
async fn handle_test_delay(&self, t: TestDelay, peer: &mut Stream) {
handle_test_delay(t, peer).await;
}
fn send(&self, data: Data) {
self.sender.send(data).ok();
}
}
#[tokio::main(flavor = "current_thread")]
pub async fn connect_test(id: &str, key: String, token: String) {
let (sender, mut receiver) = mpsc::unbounded_channel::<Data>();
let handler = Session::new(&id, sender);
match crate::client::Client::start(id, &key, &token, ConnType::PORT_FORWARD, handler).await {
Err(err) => {
log::error!("Failed to connect {}: {}", &id, err);
}
Ok((mut stream, direct)) => {
log::info!("direct: {}", direct);
// rpassword::prompt_password("Input anything to exit").ok();
loop {
tokio::select! {
res = hbb_common::timeout(READ_TIMEOUT, stream.next()) => match res {
Err(_) => {
log::error!("Timeout");
break;
}
Ok(Some(Ok(bytes))) => {
if let Ok(msg_in) = Message::parse_from_bytes(&bytes) {
match msg_in.union {
Some(message::Union::Hash(hash)) => {
log::info!("Got hash");
break;
}
_ => {}
}
}
}
_ => {}
}
}
}
}
}
}
#[tokio::main(flavor = "current_thread")]
pub async fn start_one_port_forward(
id: String,
port: i32,
remote_host: String,
remote_port: i32,
key: String,
token: String,
) {
crate::common::test_rendezvous_server();
crate::common::test_nat_type();
let (sender, mut receiver) = mpsc::unbounded_channel::<Data>();
let handler = Session::new(&id, sender);
if let Err(err) = crate::port_forward::listen(
handler.id.clone(),
handler.password.clone(),
port,
handler.clone(),
receiver,
&key,
&token,
handler.lc.clone(),
remote_host,
remote_port,
)
.await
{
log::error!("Failed to listen on {}: {}", port, err);
}
log::info!("port forward (:{}) exit", port);
}

View File

@@ -96,6 +96,8 @@ pub mod screenshot;
pub const MILLI1: Duration = Duration::from_millis(1);
pub const SEC30: Duration = Duration::from_secs(30);
// Empirical restart reconnect grace window.
const RESTART_REMOTE_DEVICE_GRACE: Duration = Duration::from_secs(5 * 60);
pub const VIDEO_QUEUE_SIZE: usize = 120;
const MAX_DECODE_FAIL_COUNTER: usize = 3;
@@ -939,21 +941,23 @@ impl Client {
#[cfg(not(target_os = "ios"))]
fn try_stop_clipboard() {
// There's a bug here.
// If session is closed by the peer, `has_sessions_running()` will always return true.
// It's better to check if the active session number.
// But it's not a problem, because the clipboard thread does not consume CPU.
//
// If we want to fix it, we can add a flag to indicate if session is active.
// But I think it's not necessary to introduce complexity at this point.
// Disconnected Flutter sessions may keep UI handlers alive, so only connected sessions
// should block clipboard cleanup.
#[cfg(feature = "flutter")]
if crate::flutter::sessions::has_sessions_running(ConnType::DEFAULT_CONN) {
if crate::flutter::sessions::has_connected_sessions_running(ConnType::DEFAULT_CONN) {
return;
}
#[cfg(not(target_os = "android"))]
clipboard_listener::unsubscribe(Self::CLIENT_CLIPBOARD_NAME);
CLIPBOARD_STATE.lock().unwrap().running = false;
#[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))]
if let Err(e) = crate::clipboard::try_empty_clipboard_files_sync(
crate::clipboard::ClipboardSide::Client,
0,
) {
log::error!("Failed to empty client clipboard files: {}", e);
}
#[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))]
clipboard::platform::unix::fuse::uninit_fuse_context(true);
}
@@ -1740,7 +1744,10 @@ pub struct LoginConfigHandler {
features: Option<Features>,
pub session_id: u64, // used for local <-> server communication
pub supported_encoding: SupportedEncoding,
pub restarting_remote_device: bool,
restarting_remote_device: bool,
// Start time of the restart grace window. On Windows the peer may briefly
// reconnect before the real reboot disconnect.
restart_remote_device_at: Option<Instant>,
pub force_relay: bool,
pub direct: Option<bool>,
pub received: bool,
@@ -1849,7 +1856,7 @@ impl LoginConfigHandler {
}
self.session_id = sid;
self.supported_encoding = Default::default();
self.restarting_remote_device = false;
self.clear_restarting_remote_device();
self.force_relay =
config::option2bool("force-always-relay", &self.get_option("force-always-relay"))
|| force_relay
@@ -2779,6 +2786,30 @@ impl LoginConfigHandler {
msg_out
}
pub fn mark_restarting_remote_device(&mut self) {
self.restarting_remote_device = true;
self.restart_remote_device_at = Some(Instant::now());
}
pub fn clear_restarting_remote_device(&mut self) {
self.restarting_remote_device = false;
self.restart_remote_device_at = None;
}
pub fn is_restarting_remote_device(&self) -> bool {
if !self.restarting_remote_device {
return false;
}
// Keep this flag alive for a short grace window instead of clearing it on
// connection_ready or the first peer bytes. During OS restart the peer can
// briefly reconnect before the real reboot disconnect, and clearing it too
// early would let the next disconnect escape the restart flow and fall back
// to the normal error dialog / manual reconnect path.
self.restart_remote_device_at
.map(|started_at| started_at.elapsed() < RESTART_REMOTE_DEVICE_GRACE)
.unwrap_or(false)
}
pub fn get_conn_token(&self) -> Option<String> {
if self.password.is_empty() {
return None;
@@ -3718,9 +3749,18 @@ pub trait Interface: Send + Clone + 'static + Sized {
fn on_establish_connection_error(&self, err: String) {
let title = "Connection Error";
let text = err.to_string();
let lc = self.get_lch();
let direct = lc.read().unwrap().direct;
let received = lc.read().unwrap().received;
let lch = self.get_lch();
let (is_restarting, direct, received) = {
let lc = lch.read().unwrap();
(lc.is_restarting_remote_device(), lc.direct, lc.received)
};
if is_restarting {
log::info!("Restart remote device, suppress connection error: {err}");
// Flutter treats this as a reconnect control event. The text is kept
// for legacy UI and existing translation reuse.
self.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
return;
}
let mut relay_hint = false;
let mut relay_hint_type = "relay-hint";
@@ -3752,6 +3792,7 @@ pub trait Interface: Send + Clone + 'static + Sized {
#[derive(Clone)]
pub enum Data {
Close,
RejectInsecureConnection,
Login((String, String, String, bool)),
Message(Message),
SendFiles((i32, JobType, String, String, i32, bool, bool)),
@@ -3775,11 +3816,33 @@ pub enum Data {
ElevateWithLogon(String, String),
NewVoiceCall,
CloseVoiceCall,
ContinueInsecureConnection,
ResetDecoder(Option<usize>),
RenameFile((i32, String, String, bool)),
TakeScreenshot((i32, String)),
}
pub async fn confirm_insecure_connection(
interface: &impl Interface,
receiver: &mut UnboundedReceiver<Data>,
) -> bool {
interface.msgbox(
"insecure-connection-nocancel-hasclose",
"Insecure Connection",
"conn-e2ee-unavailable-tip",
"",
);
while let Some(data) = receiver.recv().await {
match data {
Data::ContinueInsecureConnection => return true,
Data::RejectInsecureConnection => return false,
Data::Close => return false,
_ => {}
}
}
false
}
/// Keycode for key events.
#[derive(Clone, Debug)]
pub enum Key {

View File

@@ -6,7 +6,6 @@ pub trait FileManager: Interface {
#[cfg(not(any(
target_os = "android",
target_os = "ios",
feature = "cli",
feature = "flutter"
)))]
fn get_home_dir(&self) -> String {
@@ -16,7 +15,6 @@ pub trait FileManager: Interface {
#[cfg(not(any(
target_os = "android",
target_os = "ios",
feature = "cli",
feature = "flutter"
)))]
fn get_next_job_id(&self) -> i32 {
@@ -26,7 +24,6 @@ pub trait FileManager: Interface {
#[cfg(not(any(
target_os = "android",
target_os = "ios",
feature = "cli",
feature = "flutter"
)))]
fn update_next_job_id(&self, id: i32) {
@@ -36,7 +33,6 @@ pub trait FileManager: Interface {
#[cfg(not(any(
target_os = "android",
target_os = "ios",
feature = "cli",
feature = "flutter"
)))]
fn read_dir(&self, path: String, include_hidden: bool) -> sciter::Value {
@@ -90,7 +86,6 @@ pub trait FileManager: Interface {
#[cfg(not(any(
target_os = "android",
target_os = "ios",
feature = "cli",
feature = "flutter"
)))]
fn confirm_delete_files(&self, id: i32, file_num: i32) {
@@ -100,7 +95,6 @@ pub trait FileManager: Interface {
#[cfg(not(any(
target_os = "android",
target_os = "ios",
feature = "cli",
feature = "flutter"
)))]
fn set_no_confirm(&self, id: i32) {

View File

@@ -10,6 +10,11 @@ use crate::{
common::get_default_sound_input,
ui_session_interface::{InvokeUiSession, Session},
};
// Empirical no-data window before exposing the restart reconnect state to the UI.
// Restart msgbox text is kept as a legacy UI fallback; Flutter handles the type as a control event.
const RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT: Duration = Duration::from_secs(5);
const KCP_CLOSE_REASON_FLUSH_DELAY: Duration = Duration::from_millis(30);
#[cfg(feature = "unix-file-copy-paste")]
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
#[cfg(any(
@@ -153,7 +158,6 @@ impl<T: InvokeUiSession> Remote<T> {
}
};
let mut last_recv_time = Instant::now();
let mut received = false;
let conn_type = if self.handler.is_file_transfer() {
ConnType::FILE_TRANSFER
@@ -180,8 +184,20 @@ impl<T: InvokeUiSession> Remote<T> {
.lock()
.unwrap()
.set_connected();
let is_secured = peer.is_secured();
self.handler
.set_connection_type(peer.is_secured(), direct, stream_type); // flutter -> connection_ready
.set_connection_type(is_secured, direct, stream_type); // flutter -> connection_ready
if !is_secured
&& !crate::common::is_direct_ip_access(&self.handler.get_id())
&& !client::confirm_insecure_connection(&self.handler, &mut self.receiver).await
{
self.send_close_reason(&mut peer, "").await;
if kcp.is_some() {
tokio::time::sleep(KCP_CLOSE_REASON_FLUSH_DELAY).await;
}
self.handle_disconnected(round);
return;
}
self.handler.update_direct(Some(direct));
if conn_type == ConnType::DEFAULT_CONN || conn_type == ConnType::VIEW_CAMERA {
self.handler
@@ -219,6 +235,7 @@ impl<T: InvokeUiSession> Remote<T> {
let mut fps_instant = Instant::now();
let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await;
let mut last_recv_time = Instant::now();
loop {
tokio::select! {
@@ -244,7 +261,7 @@ impl<T: InvokeUiSession> Remote<T> {
} else {
if self.handler.is_restarting_remote_device() {
log::info!("Restart remote device");
self.handler.msgbox("restarting", "Restarting remote device", "remote_restarting_tip", "");
self.handler.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
} else {
log::info!("Reset by the peer");
self.handler.msgbox("error", "Connection Error", "Reset by the peer", "");
@@ -279,6 +296,12 @@ impl<T: InvokeUiSession> Remote<T> {
}
}
_ = status_timer.tick() => {
if self.handler.is_restarting_remote_device()
&& last_recv_time.elapsed() >= RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT
{
self.handler.msgbox("restarting-show", "Restarting remote device", "Connection in progress. Please wait.", "");
break;
}
let elapsed = fps_instant.elapsed().as_millis();
if elapsed < 1000 {
continue;
@@ -328,13 +351,17 @@ impl<T: InvokeUiSession> Remote<T> {
self.send_close_reason(&mut peer, "kcp").await;
// KCP does not send messages immediately, so wait to ensure the last message is sent.
// 1ms works in my test, but 30ms is more reliable.
tokio::time::sleep(Duration::from_millis(30)).await;
tokio::time::sleep(KCP_CLOSE_REASON_FLUSH_DELAY).await;
}
}
Err(err) => {
self.handler.on_establish_connection_error(err.to_string());
}
}
self.handle_disconnected(round);
}
fn handle_disconnected(&self, round: u32) {
// set_disconnected_ok is used to check if new connection round is started.
let _set_disconnected_ok = self
.handler
@@ -350,6 +377,8 @@ impl<T: InvokeUiSession> Remote<T> {
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
if self.handler.is_default() && _set_disconnected_ok {
// Linux client cleanup runs synchronously in try_stop_clipboard() before FUSE is
// unmounted. Keep this async path for other file-clipboard platforms.
crate::clipboard::try_empty_clipboard_files(ClipboardSide::Client, self.client_conn_id);
}
}
@@ -1078,6 +1107,9 @@ impl<T: InvokeUiSession> Remote<T> {
}
async fn send_toggle_virtual_display_msg(&self, peer: &mut Stream) {
if self.handler.is_view_camera() {
return;
}
if !self.peer_info.is_support_virtual_display() {
return;
}
@@ -1099,6 +1131,9 @@ impl<T: InvokeUiSession> Remote<T> {
}
async fn send_toggle_privacy_mode_msg(&self, peer: &mut Stream) {
if self.handler.is_view_camera() {
return;
}
let lc = self.handler.lc.read().unwrap();
if lc.version >= hbb_common::get_version_number("1.2.4")
&& lc.get_toggle_option("privacy-mode")
@@ -1426,7 +1461,11 @@ impl<T: InvokeUiSession> Remote<T> {
self.handler.set_cursor_position(cp);
}
Some(message::Union::Clipboard(cb)) => {
if !self.handler.lc.read().unwrap().disable_clipboard.v {
let clipboard_allowed = {
let lc = self.handler.lc.read().unwrap();
!lc.disable_clipboard.v && !lc.view_only.v
};
if clipboard_allowed {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
update_clipboard(vec![cb], ClipboardSide::Client);
#[cfg(target_os = "ios")]
@@ -1445,7 +1484,11 @@ impl<T: InvokeUiSession> Remote<T> {
}
}
Some(message::Union::MultiClipboards(_mcb)) => {
if !self.handler.lc.read().unwrap().disable_clipboard.v {
let clipboard_allowed = {
let lc = self.handler.lc.read().unwrap();
!lc.disable_clipboard.v && !lc.view_only.v
};
if clipboard_allowed {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
update_clipboard(_mcb.clipboards, ClipboardSide::Client);
#[cfg(target_os = "ios")]

View File

@@ -151,41 +151,50 @@ pub fn update_clipboard_files(files: Vec<String>, side: ClipboardSide) {
#[cfg(feature = "unix-file-copy-paste")]
pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) {
std::thread::spawn(move || {
let mut ctx = CLIPBOARD_CTX.lock().unwrap();
if ctx.is_none() {
match ClipboardContext::new() {
Ok(x) => {
*ctx = Some(x);
}
Err(e) => {
log::error!("Failed to create clipboard context: {}", e);
return;
}
}
}
#[allow(unused_mut)]
if let Some(mut ctx) = ctx.as_mut() {
#[cfg(target_os = "linux")]
{
use clipboard::platform::unix;
if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) {
ctx.try_empty_clipboard_files(_side);
}
}
#[cfg(target_os = "macos")]
{
ctx.try_empty_clipboard_files(_side);
// No need to make sure the context is enabled.
clipboard::ContextSend::proc(|context| -> ResultType<()> {
context.empty_clipboard(_conn_id).ok();
Ok(())
})
.ok();
}
if let Err(e) = try_empty_clipboard_files_sync(_side, _conn_id) {
log::error!("Failed to empty clipboard files: {}", e);
}
});
}
#[cfg(feature = "unix-file-copy-paste")]
pub fn try_empty_clipboard_files_sync(_side: ClipboardSide, _conn_id: i32) -> ResultType<()> {
let mut ctx = CLIPBOARD_CTX.lock().unwrap();
if ctx.is_none() {
match ClipboardContext::new() {
Ok(x) => {
*ctx = Some(x);
}
Err(e) => {
log::error!("Failed to create clipboard context: {}", e);
bail!("Failed to create clipboard context: {}", e);
}
}
}
#[allow(unused_mut)]
if let Some(mut ctx) = ctx.as_mut() {
#[cfg(target_os = "linux")]
{
use clipboard::platform::unix;
if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) {
ctx.try_empty_clipboard_files(_side);
}
}
#[cfg(target_os = "macos")]
{
ctx.try_empty_clipboard_files(_side);
// No need to make sure the context is enabled.
clipboard::ContextSend::proc(|context| -> ResultType<()> {
if !context.empty_clipboard(_conn_id)? {
bail!("Failed to empty clipboard files for conn_id {}", _conn_id);
}
Ok(())
})?;
}
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn try_empty_clipboard_files(side: ClipboardSide, conn_id: i32) {
log::debug!("try to empty {} cliprdr for conn_id {}", side, conn_id);
@@ -868,6 +877,7 @@ pub mod clipboard_listener {
.unwrap()
.insert(name.clone(), tx);
cleanup_stale_listener(&mut listener_lock);
if listener_lock.handle.is_none() {
log::info!("Start clipboard listener thread");
let handler = Handler {
@@ -893,6 +903,24 @@ pub mod clipboard_listener {
Ok(())
}
fn cleanup_stale_listener(listener: &mut ClipboardListener) {
if !listener
.handle
.as_ref()
.map(|(_, h)| h.is_finished())
.unwrap_or(false)
{
return;
}
if let Some((shutdown, h)) = listener.handle.take() {
log::warn!("Cleaning up stale clipboard listener handle");
if let Err(e) = h.join() {
log::error!("Clipboard listener thread panicked during stale cleanup: {:?}", e);
}
drop(shutdown);
}
}
pub fn unsubscribe(name: &str) {
log::info!("Unsubscribe clipboard listener: {}", name);
let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap();

View File

@@ -332,12 +332,16 @@ pub mod unix_file_clip {
log::debug!("format data response: msg_flags: {}", msg_flags);
if msg_flags != 0x1 {
// return failure message?
log::error!(
"peer reported clipboard format data failure: {}",
msg_flags
);
return vec![];
}
log::debug!("parsing file descriptors");
if fuse::init_fuse_context(true).is_ok() {
match fuse::format_data_response_to_urls(
match fuse::init_fuse_context(side == ClipboardSide::Client) {
Ok(()) => match fuse::format_data_response_to_urls(
side == ClipboardSide::Client,
format_data,
conn_id,
@@ -348,9 +352,10 @@ pub mod unix_file_clip {
Err(e) => {
log::error!("failed to parse file descriptors: {:?}", e);
}
},
Err(e) => {
log::error!("failed to initialize clipboard FUSE context: {:?}", e);
}
} else {
// send error message to server
}
}
ClipboardFile::FileContentsRequest {
@@ -386,6 +391,7 @@ pub mod unix_file_clip {
ClipboardFile::FileContentsResponse {
msg_flags,
stream_id,
requested_data,
..
} => {
log::debug!(
@@ -393,13 +399,15 @@ pub mod unix_file_clip {
msg_flags,
stream_id,
);
if fuse::init_fuse_context(true).is_ok() {
hbb_common::allow_err!(fuse::handle_file_content_response(
side == ClipboardSide::Client,
clip
));
} else {
// send error message to server
let response = ClipboardFile::FileContentsResponse {
msg_flags,
stream_id,
requested_data,
};
if let Err(e) =
fuse::handle_file_content_response(side == ClipboardSide::Client, response)
{
log::error!("failed to handle file contents response: {:?}", e);
}
}
ClipboardFile::NotifyCallback {

View File

@@ -764,15 +764,14 @@ async fn test_rendezvous_server_() {
Config::reset_online();
}
// #[cfg(any(target_os = "android", target_os = "ios", feature = "cli"))]
pub fn test_rendezvous_server() {
std::thread::spawn(test_rendezvous_server_);
}
pub fn refresh_rendezvous_server() {
#[cfg(any(target_os = "android", target_os = "ios", feature = "cli"))]
#[cfg(any(target_os = "android", target_os = "ios"))]
test_rendezvous_server();
#[cfg(not(any(target_os = "android", target_os = "ios", feature = "cli")))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
std::thread::spawn(|| {
if crate::ipc::test_rendezvous_server().is_err() {
test_rendezvous_server();
@@ -1025,7 +1024,7 @@ pub fn get_full_name() -> String {
}
pub fn is_setup(name: &str) -> bool {
name.to_lowercase().ends_with("install.exe")
!config::is_disable_installation() && name.to_lowercase().ends_with("install.exe")
}
pub fn get_custom_rendezvous_server(custom: String) -> String {
@@ -2620,6 +2619,24 @@ pub fn get_control_permission(
}
}
pub fn is_direct_ip_access(peer: &str) -> bool {
hbb_common::is_ip_str(peer) || hbb_common::is_domain_port_str(peer)
}
// Align the maximum length of the peer id to the maximum length of the peer id in the server.
const MAX_UNTRUSTED_PEER_ID_LEN: usize = 253;
const UNTRUSTED_PEER_ID_FORBIDDEN_CHARS: &[char] = &['"', '<', '>', '/', '\\', '|', '?', '*'];
// Shared validation for peer/connect ids that cross untrusted boundaries before
// they are stored or written into command/script contexts.
pub fn is_valid_untrusted_peer_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_UNTRUSTED_PEER_ID_LEN
&& !id.chars().any(|ch| {
ch.is_control() || ch.is_whitespace() || UNTRUSTED_PEER_ID_FORBIDDEN_CHARS.contains(&ch)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2650,6 +2667,29 @@ mod tests {
)
}
#[test]
fn untrusted_peer_id_validation() {
let cases = [
("123456789", true),
("m\u{00FC}nchen-pc", true),
("192.168.1.10:21118", true),
("9123456234@public", true),
(
r#"1" & oWS.Run("cmd.exe /k whoami /priv",1,False) & ""#,
false,
),
("", false),
("peer id", false),
("peer\nid", false),
("peer/id", false),
("peer?id", false),
];
for (id, expected) in cases {
assert_eq!(is_valid_untrusted_peer_id(id), expected, "{id:?}");
}
}
// ThrottledInterval tick at the same time as tokio interval, if no sleeps
#[allow(non_snake_case)]
#[tokio::test]

View File

@@ -127,6 +127,13 @@ pub fn core_main() -> Option<Vec<String>> {
if args.contains(&"--noinstall".to_string()) {
args.clear();
}
// The portable wrapper injects `--install` when its name ends with `install.exe`,
// including `no-install.exe`. Drop the argument instead of exiting so disabled
// clients can continue running as portable applications.
if config::is_disable_installation() {
args.retain(|arg| arg != "--install");
flutter_args.retain(|arg| arg != "--install");
}
if args.len() > 0 {
if args[0] == "--version" {
println!("{}", crate::VERSION);
@@ -262,11 +269,9 @@ pub fn core_main() -> Option<Vec<String>> {
if config::is_disable_installation() {
return None;
}
#[cfg(not(windows))]
let options = "desktopicon startmenu";
#[cfg(windows)]
let options = "desktopicon startmenu printer";
let res = platform::install_me(options, "".to_owned(), true, args.len() > 1);
let (printer_override, debug) = parse_silent_install_args(&args);
let options = platform::get_silent_install_options(printer_override);
let res = platform::install_me(options, "".to_owned(), true, debug);
let text = match res {
Ok(_) => translate("Installation Successful!".to_string()),
Err(err) => {
@@ -662,7 +667,8 @@ pub fn core_main() -> Option<Vec<String>> {
None
}
};
let new_id = get_value("--id");
// An empty --id (e.g. an unset var) would deploy a blank id; the Android flow guards this too (#15146).
let new_id = get_value("--id").filter(|s| !s.is_empty());
match crate::ui_interface::deploy_device(token, new_id) {
crate::ui_interface::DeployResult::Ok => {
println!("Device deployed.");
@@ -933,6 +939,23 @@ fn is_cli_setting_change_disabled() -> bool {
config::is_disable_settings() && !allow_command_line_settings
}
#[cfg(windows)]
fn parse_silent_install_args(args: &[String]) -> (Option<bool>, bool) {
let mut printer_override = None;
let mut debug = false;
for arg in args.iter().skip(1) {
match arg.as_str() {
"printer=1" => printer_override = Some(true),
"printer=0" => printer_override = Some(false),
"debug" => debug = true,
_ => {}
}
}
(printer_override, debug)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -136,6 +136,12 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c
return std::ptr::null_mut() as _;
}
#[cfg(windows)]
#[no_mangle]
pub extern "C" fn rustdesk_is_disable_installation() -> c_int {
hbb_common::config::is_disable_installation() as c_int
}
// https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6
#[cfg(windows)]
fn rust_args_to_c_args(args: Vec<String>, outlen: *mut c_int) -> *mut *mut c_char {
@@ -1437,7 +1443,7 @@ fn try_send_close_event(event_stream: &Option<StreamSink<EventToUI>>) {
pub fn update_text_clipboard_required() {
let is_required = sessions::get_sessions()
.iter()
.any(|s| s.is_text_clipboard_required());
.any(|s| s.is_default() && s.is_text_clipboard_required());
#[cfg(target_os = "android")]
let _ = scrap::android::ffi::call_clipboard_manager_enable_client_clipboard(is_required);
Client::set_is_text_clipboard_required(is_required);
@@ -1447,13 +1453,16 @@ pub fn update_text_clipboard_required() {
pub fn update_file_clipboard_required() {
let is_required = sessions::get_sessions()
.iter()
.any(|s| s.is_file_clipboard_required());
.any(|s| s.is_default() && s.is_file_clipboard_required());
Client::set_is_file_clipboard_required(is_required);
}
#[cfg(not(target_os = "ios"))]
pub fn send_clipboard_msg(msg: Message, _is_file: bool) {
for s in sessions::get_sessions() {
if !s.is_default() {
continue;
}
#[cfg(feature = "unix-file-copy-paste")]
if _is_file {
if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version)
@@ -2297,6 +2306,16 @@ pub mod sessions {
*r#type == conn_type && s.session_handlers.read().unwrap().len() != 0
})
}
#[inline]
#[cfg(not(target_os = "ios"))]
pub fn has_connected_sessions_running(conn_type: ConnType) -> bool {
SESSIONS.read().unwrap().iter().any(|((_, r#type), s)| {
*r#type == conn_type
&& s.session_handlers.read().unwrap().len() != 0
&& s.connection_round_state.lock().unwrap().is_connected()
})
}
}
pub(super) mod async_tasks {

View File

@@ -326,12 +326,14 @@ pub fn session_toggle_option(session_id: SessionID, value: String) {
try_sync_peer_option(&session, &session_id, &value, None);
}
#[cfg(not(target_os = "ios"))]
if sessions::get_session_by_session_id(&session_id).is_some() && value == "disable-clipboard" {
if sessions::get_session_by_session_id(&session_id).is_some()
&& (value == "disable-clipboard" || value == "view-only")
{
crate::flutter::update_text_clipboard_required();
}
#[cfg(feature = "unix-file-copy-paste")]
if sessions::get_session_by_session_id(&session_id).is_some()
&& value == config::keys::OPTION_ENABLE_FILE_COPY_PASTE
&& (value == config::keys::OPTION_ENABLE_FILE_COPY_PASTE || value == "view-only")
{
crate::flutter::update_file_clipboard_required();
}
@@ -1024,7 +1026,7 @@ pub fn main_set_option(key: String, value: String) {
set_option(key, value.clone());
#[cfg(target_os = "android")]
crate::rendezvous_mediator::RendezvousMediator::restart();
#[cfg(any(target_os = "android", target_os = "ios", feature = "cli"))]
#[cfg(any(target_os = "android", target_os = "ios"))]
crate::common::test_rendezvous_server();
} else {
set_option(key, value.clone());
@@ -1222,9 +1224,14 @@ pub fn main_set_local_option(key: String, value: String) {
let is_texture_render_key = key.eq(config::keys::OPTION_TEXTURE_RENDER);
let is_d3d_render_key = key.eq(config::keys::OPTION_ALLOW_D3D_RENDER);
set_local_option(key, value.clone());
let is_render_target =
|session: &crate::flutter::FlutterSession| session.is_default() || session.is_view_camera();
if is_texture_render_key {
let session_event = [("v", &value)];
for session in sessions::get_sessions() {
if !is_render_target(&session) {
continue;
}
session.push_event("use_texture_render", &session_event, &[]);
session.use_texture_render_changed();
session.ui_handler.update_use_texture_render();
@@ -1232,6 +1239,9 @@ pub fn main_set_local_option(key: String, value: String) {
}
if is_d3d_render_key {
for session in sessions::get_sessions() {
if !is_render_target(&session) {
continue;
}
session.update_supported_decodings();
}
}
@@ -2850,8 +2860,16 @@ pub fn main_get_common(key: String) -> String {
crate::platform::windows::is_msi_installed(),
crate::common::is_custom_client(),
) {
(Ok(true), false) => format!("rustdesk-{_version}-x86_64.msi"),
(Ok(true), true) | (Ok(false), _) => format!("rustdesk-{_version}-x86_64.exe"),
(Ok(true), false) => match crate::platform::windows::release_arch_suffix() {
Some(arch) => format!("rustdesk-{_version}-{arch}.msi"),
None => "error:unsupported".to_owned(),
},
(Ok(true), true) | (Ok(false), _) => {
match crate::platform::windows::release_arch_suffix() {
Some(arch) => format!("rustdesk-{_version}-{arch}.exe"),
None => "error:unsupported".to_owned(),
}
}
(Err(e), _) => {
log::error!("Failed to check if is msi: {}", e);
format!("error:update-failed-check-msi-tip")
@@ -3010,6 +3028,16 @@ pub fn main_set_common(_key: String, _value: String) {
}
}
pub fn session_set_common(session_id: SessionID, key: String, value: String) {
if let Some(s) = sessions::get_session_by_session_id(&session_id) {
if key == "continue-insecure-connection"
{
s.continue_insecure_connection(value == "Y");
return;
}
}
}
pub fn session_get_common_sync(
session_id: SessionID,
key: String,

Some files were not shown because too many files have changed in this diff Show More