Compare commits

...

129 Commits

Author SHA1 Message Date
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
rustdesk
0c86d46162 translate all 2026-06-02 23:33:40 +08:00
bovirus
e87797418f Update it.rs (#15173) 2026-06-02 23:32:04 +08:00
Lynilia
78a3a2aeb9 Update fr.rs (#15172) 2026-06-02 22:26:19 +08:00
bovirus
e18cf7a245 Update it.rs (#15171) 2026-06-02 22:23:06 +08:00
rustdesk
50d5823ef5 1.4.7 2026-06-02 17:06:23 +08:00
fufesou
518296f257 fix: bytes codec, reserver(), check max (#15168)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-02 16:39:50 +08:00
fufesou
3217125dd3 fix(keyboard): wayland clipboard input prompt (#14700)
* fix(keyboard): wayland clipboard input prompt

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

* fix(wayland): Simple refactor

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

* fix(wayland): clipboard input, remove unused code

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

* fix(wayland): Simple refactor

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

* fix(wayland): dialog, better enableAndContinue

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

* fix(wayland): input dialog consent

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

* fix(wayland): prompt text

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

* fix(wayland): text input

1. Use `keysym` for the installed version if possible.
2. Use the clipboard if the string cannot be fully handled by `keysym`.

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

* fix(wayland): input prompt dialog

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

* fix(wayland): translations

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

* fix(wayland): dialog, title type

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

* fix(wayland): better decode_utf8_prefix()

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

* fix(wayland): better process_chr()

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

* fix(wayland): unit tests

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

* fix(wayland): input prompt dialog, no icon

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

* fix(wayland): input dialog, Toast show the result

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

* fix(wayland): input dialog, showToast() on persist failed

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

* fix(wayland): input prompt, better dialog

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

* fix(wayland): input prompt dialog, translations

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

* fix(input): better wayland clipboard input prompt

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

* fix(input): wayland clipboard, link external app

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

* fix(input): trivial changes

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

* fix(input): wayland clipboard input, dialog content

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

* fix(input): tranlsations

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

* fix(input): translations

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

* fix(input): translations

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

* fix(input): translations

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-02 16:06:35 +08:00
bovirus
00032854eb Update it.rs (#15167)
* Update it.rs

* Update it.rs
2026-06-02 16:00:43 +08:00
21pages
d99ddf6816 Add Android device deployment flow (#15146)
* Add Android device deployment flow

  Notify the Android Flutter UI when the server requires deployment, add a deploy dialog with API token/custom ID inputs, and reuse shared deploy logic
  for CLI and FFI

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

* Hide Android deploy API token input

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

* add more translations

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

* optimize transations

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

* Hide deploy action for outgoing-only clients

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

* Fix deployment register throttle state reset

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

* Move Android deploy dialog out of settings page

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

* Use async mutex for deploy register throttle

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-06-02 14:28:30 +08:00
RustDesk
32c6e32e04 Revert "Revert "fix: add integer overflow check in wf_cliprdr.c (#15142)" (#1…" (#15166)
This reverts commit c55b1f3359.
2026-06-02 11:45:24 +08:00
RustDesk
c55b1f3359 Revert "fix: add integer overflow check in wf_cliprdr.c (#15142)" (#15160)
This reverts commit fabeae4180.
2026-06-01 16:26:20 +08:00
OrbisAI Security
fabeae4180 fix: add integer overflow check in wf_cliprdr.c (#15142)
* fix: V-003 security vulnerability

Automated security fix generated by OrbisAI Security

Signed-off-by: orbisai0security <mediratta01.pally@gmail.com>

* fix: add integer overflow check in wf_cliprdr.c

At line 774, memory is allocated using calloc with instance->m_nStreams as the count parameter

Signed-off-by: orbisai0security <mediratta01.pally@gmail.com>

* Apply code changes: @orbisai0security can you address code review comm...

* fix(cliprdr): ci

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

* fix(cliprdr): ci, use msvc

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

* fix(cliprdr): ci

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

* fix(cliprdr): ci, test

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

* fix(cliprdr): fix ci

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

* fix(cliprdr): fix ci

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

* fix(cliprdr): fix ci

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

* fix(cliprdr): ci

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

* Apply code changes: @orbisai0security can you address code review comm...

* adding bounds check and tests

* Apply code changes: @orbisai0security can you address code review comm...

* Potential fix for pull request finding

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

---------

Signed-off-by: orbisai0security <mediratta01.pally@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-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-01 15:26:39 +08:00
fufesou
5eed50961d fix(crypt): symmetric crypt, zero nonce (#15144)
* fix(crypt): symmetric crypt, zero nonce

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

* update hbb_common

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-01 12:01:26 +08:00
Maison da Silva
bed0976eb9 Fix Portuguese translations in ptbr.rs (#15149)
Corrected Portuguese translations for consistency and clarity.
2026-06-01 10:38:31 +08:00
VenusGirl❤
70d92d9b07 Update Korean README with improved formatting (#15153) 2026-06-01 10:38:04 +08:00
Maison da Silva
fb4ba31504 Revise README-PTBR for clarity and updates (#15152)
Updated various sections of the README in Portuguese, including links, instructions, and descriptions for clarity and consistency.
2026-06-01 10:37:32 +08:00
Muad'Dib
152c5c71b1 fix(android): close session on dispose to prevent reconnect wedge (#15143)
RemotePage.dispose() only reaches sessionClose at the tail of gFFI.close(),
behind several awaits (canvas save, image update, the enable_soft_keyboard
platform call). If the app is backgrounded while the page is disposing,
dispose can be suspended before that runs, so the session is never torn down.
The next reconnect re-attaches to the leaked session (mobile reuses a constant
sessionId) and is stuck on "Connecting..." forever while the orphaned io_loop
keeps streaming.

Dispatch sessionClose at the start of dispose so teardown happens synchronously
on route pop, before backgrounding can interrupt it. The sessionClose in
gFFI.close() becomes a no-op once the session is already removed.

Fixes #15060

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:24:53 +08:00
Maison da Silva
fa369365a5 Update Portuguese translations for clarity (#15135)
* Update Portuguese translations for clarity

Update Portuguese translations for clarity

* Update ptbr.rs

* Update Portuguese translations for clarity

* Update Portuguese translation for version warning

* Refine Portuguese translations in ptbr.rs

Updated translations for user input blocking and OS password.

* Fix translation for 'Take screenshot' in Portuguese

* Change translation for 'Note' to 'Anotações'

* Update translation from 'nota' to 'anotação'
2026-05-30 15:44:04 +08:00
hatterp
7345366ba7 Add Polish translations for privacy mode and toolbar docking (#15134) 2026-05-29 17:52:42 +08:00
Kleofass
6151ea7128 Update lv.rs (#15133) 2026-05-29 14:09:43 +08:00
Alex Rijckaert
440ab26b69 Update Dutch translations (#15132) 2026-05-29 08:49:27 +08:00
21pages
caadd72ab2 Add advanced option to allow CLI settings when custom client toggles Disable settings (#15138) 2026-05-28 17:52:55 +08:00
Mr-Update
d59d543ec1 Update de.rs (#15131) 2026-05-28 14:56:19 +08:00
bilimiyorum
58d1109510 Update tr.rs (#15119)
New string entry
2026-05-28 14:52:41 +08:00
bovirus
9c52e25a6a Update Italian language (#15118) 2026-05-28 13:42:14 +08:00
solokot
62a44c5a09 Update ru.rs (#15117) 2026-05-28 13:41:48 +08:00
MichaIng
e5fa40e903 fix(packaging): add support for time64 packages (#14465)
Debian 13 Trixie and Ubuntu 24.04 Noble come with time64 transitioned packages: https://wiki.debian.org/ReleaseGoals/64bit-time
This means, all packages with use the time_t syscall on 32-bit do now use the time64 syscall instead, to get 64-bit year 2038 prove UNIX time values. Those packages get a "t64" suffix for their name, also for 64-bit architectures for consistency. Since time_t values on 64-bit are 64-bit already, no actual change happened there, and a package dependency without the t64 suffix is still satisfied by the packages with t64 suffix, via "Provides" attribute. This however is not he case for 32-bit.

The rustdesk package currently depends on libgtk-3-0 and libasound2, while Debian Trixie and Ubuntu Noble serve libgtk-3-0t64 and libasound2t64. On 64-bit architectures (amd64 and arm64), the available packages satisfy the dependency, but on 32-bit (armhf) this is not the case. In turn the rustdesk armv7-sciter.deb package cannot be installed on recent distro versions.

This commit solves the issue by adding the respective t64 packages are alternative dependency. If available, the t64 package is installed, else (on older distro versions), the one without t64 suffix.

Signed-off-by: MichaIng <micha@dietpi.com>
2026-05-27 15:05:37 +08:00
VenusGirl❤
8177083992 Update Korean (#15113) 2026-05-27 14:10:39 +08:00
Maison da Silva
4bfd8e9f61 Fix Portuguese translations for consistency (#15112)
* Fix Portuguese translations for consistency

Fix Portuguese translations for consistency

* Update translation for screenshot action tip

* Fix capitalization in Portuguese translations

* Fix translation for remote session display usage

* Translate allow-remote-toolbar-docking-any-edge message
2026-05-27 14:10:17 +08:00
rustdesk
81e7d27ec8 pin more action 2026-05-26 19:03:03 +08:00
rustdesk
f3fc0b5ac2 pin unpinned action 2026-05-26 18:08:28 +08:00
rustdesk
fb7bca436b fix ci 2026-05-26 17:08:32 +08:00
rustdesk
c19a0ceba2 more "cargo build --locked" 2026-05-26 11:45:15 +08:00
fufesou
1f26e452fc refact(password): encrypt (#15073)
* refact(password): encrypt

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

* refact(password): simplify preset password

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

* update hbb_common

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

* refact(password): clear password, do not clear salt

* refact(password): update hbb_common

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

* refact(password): merge import

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-26 11:11:25 +08:00
Lynilia
0af6b7ede9 Update fr.rs (#15111) 2026-05-25 16:01:34 +08:00
Luke
6ad56075d6 Drag whole toolbar; snap to all four edges of the remote session window (#15051)
* Drag whole toolbar; snap to all four edges

Today the drag handle on the remote-session toolbar repositions only
the handle row -- the icons themselves stay centered at the top. This
change applies the position to the entire toolbar wrapper so dragging
the handle moves the whole thing, and extends snapping from top-only
to any of the four window edges.

When docked left/right the toolbar reflows vertically. A live ghost
preview shows where the toolbar will land while you drag, with a small
hysteresis bias to keep the preview from flickering near corners.

The legacy 'remote-menubar-drag-x' session option is read as a fallback
on first load so existing users keep their saved horizontal position;
new option keys are 'remote-menubar-edge' and 'remote-menubar-frac'.

Tested locally on Windows. macOS / Linux / web desktop use the same
shared widget with no platform-specific calls, but I did not verify
them.

* Load edge independently and clamp loaded fraction

Addresses CodeRabbit review on #15051: parse the saved edge regardless
of whether the new fraction option is present so a partial write of
frac doesn't reset the toolbar back to top, and clamp the loaded
fraction to the kOptionRemoteMenubarDragLeft/Right contract so a
corrupted or out-of-range saved value can't bypass the bounds until
the user drags again.

* Require edge activation zone to switch dock; preserve horizontal slide

Per review feedback on #15051: nearest-edge-wins made a low-intent
horizontal slide too easy to escalate into a high-impact orientation
change (vertical reflow on left/right dock). The default drag now
keeps the toolbar on its current dock edge and just updates the
fraction along that edge -- the prior horizontal-slide behavior.

An alternate edge is only previewed/committed when the cursor enters
its 32 px activation zone; once previewed, the cursor has to move
back 64 px before reverting (hysteresis at the zone boundary).

* Gate multi-edge docking behind a settings toggle; default = horizontal slide

Replaces the activation-zone approach with an explicit opt-in setting
in Settings -> Other ("Allow docking remote toolbar to any window
edge"). This addresses the concern that a low-intent horizontal drag
shouldn't be able to trigger a high-impact orientation change, while
still letting users who want multi-edge docking opt in cleanly.

Default (toggle off):
  - The original horizontal slide is preserved.
  - The bug fix from the first commit still applies: dragging the
    handle moves the whole toolbar, and the position persists across
    collapse/expand (no more re-center on re-open).
  - Draggable is axis-locked to horizontal so the feedback widget
    stays on the top line during drag.

Opt-in (toggle on):
  - Full nearest-edge wins with the live preview ghost and corner
    hysteresis; toolbar reflows vertically on left/right docks.
  - Draggable is unlocked for 2D drag.

Reads the option via mainGetLocalBoolOptionSync so the toolbar's
default state matches what the settings checkbox shows; the option
key uses the allow- prefix so unset defaults to off.

Takes effect on next session (setting is read at session init).

The setting key (allow-multi-edge-toolbar-dock) is read by the
existing local-options machinery and persists per-install without
needing to be registered in libs/hbb_common's KEYS_LOCAL_SETTINGS.
Can add that registration in a parallel hbb_common PR if preferred.

* Fix remote toolbar drag positioning & persistence

Align drag fraction calculation with the toolbar's actual travel range,
keep preview sizing stable during drag, and preserve legacy horizontal
position storage when multi-edge docking is disabled.

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

* Remote toolbar snap edges

1. Translations
2. Apply option to remote windows on changed

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

* fix: avoid remote toolbar docking jumps on setting reload

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

* Fix remote toolbar docking updates and drag sync

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

* refact: translation key

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

* feat(toolbar-snap-edges): test web

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

* Fix remote toolbar docking sync and vertical layout

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

* Fix remote toolbar monitor controls on side docks

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-05-24 19:08:45 +08:00
Maison da Silva
b81ae6c894 Translate various labels to Portuguese-BR (#15086)
Update
2026-05-22 18:36:15 +08:00
dependabot[bot]
546e9f1702 Git submodule: Bump libs/hbb_common from c8cbb6b to 9043c15 (#15067)
Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `c8cbb6b` to `9043c15`.
- [Release notes](https://github.com/rustdesk/hbb_common/releases)
- [Commits](c8cbb6be28...9043c15acc)

---
updated-dependencies:
- dependency-name: libs/hbb_common
  dependency-version: 9043c15acc6d5b42b6c12ad284c16c1ec172f1f0
  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-05-21 09:07:43 +08:00
fufesou
bb51c6aa42 fix(ipc): cmdline, unit tests (#15069)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-18 17:03:04 +08:00
fufesou
78e8134ad5 fix(ipc): cmdline, use scope, deploy (#15068)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-18 16:52:22 +08:00
fufesou
bc2c36215d fix(ipc): scope active-user IPC routing to root CLI main requests (#15058)
* fix(ipc): scope active-user IPC routing to root CLI main requests

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

* fix(ipc): cmdline, comments fails close

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

* fix(ipc): cmdline, better check

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

* fix(ipc): cmdline, try active uid when no --server processes

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

* fix(ipc): cmdline, select active uid

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

* fix(ipc): remove unused import

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-18 16:32:46 +08:00
IronCodeStudios
377547fa11 scrap/wayland: insert videoconvert to fix screencast on COSMIC / DMA-BUF portals (#15063)
On Wayland compositors whose xdg-desktop-portal backend exposes screencast
frames as DMA-BUF buffers — notably xdg-desktop-portal-cosmic 0.1.0 on
Pop!_OS 24.04 / COSMIC — inbound screen capture fails. PipeWireRecorder
links pipewiresrc directly to an appsink whose caps only accept
video/x-raw BGRx/RGBx in system memory. That format set is too narrow for
the portal's buffer-type / modifier negotiation, which collapses with:

  pw.link: negotiating -> error no more output formats (-22)
  gstpipewiresrc: stream error: no more output formats
  gstbasesrc: streaming stopped, reason not-negotiated (-4)
  ERROR src/server/wayland.rs: Failed scrap Element failed to change its state

Inserting a videoconvert element between pipewiresrc and appsink widens
the negotiable format set to any system-memory video/x-raw format, giving
the portal room to settle on a format it can deliver via its SHM path.
videoconvert then converts to the BGRx/RGBx the appsink expects.

Verified on Pop!_OS 24.04 / COSMIC with gst-launch, before and after:

  # fails (current behaviour):
  gst-launch-1.0 pipewiresrc path=N ! video/x-raw,format=BGRx ! fakesink
  # works (with this change):
  gst-launch-1.0 pipewiresrc path=N ! videoconvert ! video/x-raw,format=BGRx ! fakesink

After the change, inbound connections capture and stream the desktop
normally and the "Failed scrap" error no longer occurs.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:02:23 +08:00
RustDesk
472c4fc03a --deploy, reuse the device token (#15035)
* --deploy, reuse the device token

* Potential fix for pull request finding

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

* fix review

* no id validation in deploy, so to keep the same behavior in udp register
pk

* Fix collapsed toolbar drag preview sizing

* Revert "Fix collapsed toolbar drag preview sizing"

This reverts commit 66e39abb74.

* remove too many logs

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-16 14:41:34 +08:00
rustdesk
9f8f726f12 fix compile 2026-05-15 17:30:59 +08:00
flusheDData
701a9c6cdc New terms added (#15036)
* Update es.rs

New terms added

* Update es.rs

New terms added

* Update Spanish translations for various strings

* Fix typo in Spanish translation for TLS fallback

* Add Spanish translations for various UI elements

* Update es.rs

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-05-15 15:31:25 +08:00
Alex Rijckaert
0d40cf2101 Update Dutch translations (#15024)
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-05-14 16:43:40 +08:00
rustdesk
dd265dadd7 update hbb_common 2026-05-13 18:08:08 +08:00
Alex Rijckaert
fe5a8cb2ad Update Dutch translation (#14984) 2026-05-13 14:59:48 +08:00
John Fowler
b6caa1a7b2 hu.rs update (#14983)
Translate a new string.
2026-05-13 14:59:29 +08:00
fufesou
55c9707639 fix(msi): check install folder, remove files when uninstall (#15011)
* fix(msi): check install folder, remove files when uninstall

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

* fix(msi): harden install folder normalization cleanup

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

* fix(msi): better file attributes

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

* fix(mis): Simple refactor

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

* fix(msi): avoid path-based attribute changes in cleanup

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

* fix(msi): custom action, unset flag read before del

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-12 16:24:50 +08:00
Yan Wang
d8808baa83 Allow macOS monitor switching in privacy mode (#15004)
Co-authored-by: Codex <codex@openai.local>
2026-05-11 12:58:49 +08:00
fufesou
1978020d27 fix(custom-client): desktop, incoming only, touch drag (#14928)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-11 12:58:32 +08:00
fufesou
0e4b91b8d7 Harden os password (terminal windows and headless linux) anti brute force (#14985)
* fix(windows): terminal, preauth bruteforce

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

* fix(linux): headless, preauth bruteforce

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

* fix(linux): headless, OS login, minimal fix

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

* Terminal session, click-only

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

* Simple refactor, logs

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

* harden os password, better scoped failure set

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

* harden os password, ip failure count

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

* Check prelogin before starting cm

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

* Isolate terminal OS login failure tracking

Terminal OS login no longer reads or updates the default RustDesk
per-IP failure bucket. It now uses only the OS credential policy, while
RustDesk password attempts keep using the existing LOGIN_FAILURES[0]
bucket.

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-11 12:58:01 +08:00
fufesou
9c831dc59b fix(fs): file transfer, reconnect, restore dir (#14925)
* fix(fs): file transfer, reconnect, restore dir

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

* fix(fs): simple refactor

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

* fix(fs): simple refactor

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-10 10:08:29 +08:00
fufesou
b757e97c11 fix(translation): ja (#14993)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-05-10 10:02:42 +08:00
fufesou
9df486a689 fix(ipc): harden local IPC authorization and portable-service bootstrap flow (#14671)
* fix(ipc): harden ipc access

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

* fix(ipc): full cmd path, comments, simple refactor

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

* fix(ipc): portable service, ipc exit

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

* fix(ipc): Remove unused logs

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

* fix(ipc): Use SetEntriesInAclW instead of icacls

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

* fix(ipc): Comments

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

* fix(ipc): check is_reparse_point

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

* fix(ipc): shmem name, no fallback

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

* fix(ipc): Simple refactor

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

* fix(ipc): better exit and clear

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

* fix(ipc): portable service, better exit

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

* fix(ipc): comments, id -u

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

* fix: comments linux headless, rx desktop ready

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

* fix(ipc): magic number

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

* fix(ipc): update deps

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

* Update Cargo.lock

* Update Cargo.lock

* fix(ipc): harden ipc, test `identity_unavailable`

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

* fix(ipc): portable service, check dir of shmem

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

* fix(ipc): macos, better check exe allowed

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

* fix(ipc): update hbb_common

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

* fix(ipc): update hbb_common

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

* fix(ipc): harden ipc, better active uid for uinput

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

* fix(ipc): harden portable service token validation

Compare portable service IPC tokens in constant time and document the
CSPRNG source used for one-time token generation. Clarify Windows IPC
authorization comments around canonical path matching and partial peer
identity lookup.

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

* fix(ipc): simple refactor

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

* fix(ipc): harden portable service token handling

Generate the portable service IPC token directly from OsRng, keep token
comparison in the IPC layer as a fixed-length byte-wise check, and document
the malformed-frame behavior for protected service IPC.

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

* fix(ipc): comments

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-05-09 18:15:00 +08:00
VenusGirl❤
72d27c3c47 Update Korean (#14956) 2026-05-08 17:49:17 +08:00
RustDesk
6c20fc936d Terminal utf8 and reconnect (#14895)
* fix: handle incomplete UTF-8 sequences in terminal output, rework on https://github.com/rustdesk/rustdesk/pull/14736

* Fix terminal auto-reconnect freeze:  reconnect resumes terminal output, while multi-tab reconnect avoids restoring duplicate tabs for terminals that are already open.

* fix(terminal): subtract with overflow

```
thread '<unnamed>' panicked at src\server\terminal_service.rs:476:17:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'tokio-runtime-worker' panicked at src\server\terminal_service.rs:1576:50:
called `Result::unwrap()` on an `Err` value: PoisonError { .. }
[2026-04-25T07:17:34Z ERROR librustdesk::server::service] Failed to join thread for service ts_9badd3fe-2411-4996-9f40-93c979009edd, Any { .. }
```

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

* fix ios enter: https://github.com/rustdesk/rustdesk/issues/14907

* fix(terminal): reconnect, error handling

1. Terminal shows "^[[1;1R^[[2;2R^[[>0;0;0c"
2. NaN

```
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Converting object to an encodable object failed: NaN
...
```

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

* fix(terminal): dialog, close window

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

* fix(terminal): close terminal window on disconnect dialog

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

* fix(terminal): merge reconnect backlog into replay output

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

* fix(terminal): avoid reconnect stalls and delayed layout writes

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

* fix(terminal): remove invalid test

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

* fix(terminal): schedule frame before flushing buffered output

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

* fix(terminal): windows&macos, charset utf-8

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

* fix(terminal): reconnect suppress next output

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

* fix: cap terminal reconnect replay output

  - split reconnect replay backlog into capped chunks
  - mark terminal data replay chunks for client-side suppression
  - avoid using open-message text to suppress xterm replies
  - reuse default terminal padding value
  - remove misleading Enter-key normalization PR link

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

* fix(terminal): env en_US.UTF-8

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

* fix(terminal): reconnect, refactor

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

* fix(terminal): flag, retry output

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

* fix(terminal): update hbb_common

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

* fix(terminal): comments

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

* fix(terminal): comments utf-8 chunk accumulator

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

* fix(terminal): update hbb_common

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-05-07 13:27:13 +08:00
197 changed files with 18859 additions and 6608 deletions

View File

@@ -2,6 +2,8 @@
rustflags = ["-Ctarget-feature=+crt-static"] rustflags = ["-Ctarget-feature=+crt-static"]
[target.i686-pc-windows-msvc] [target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"] 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")'] [target.'cfg(target_os="macos")']
rustflags = [ rustflags = [
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null", "-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: env:
CARGO_EXPAND_VERSION: "1.0.95" CARGO_EXPAND_VERSION: "1.0.95"
FLUTTER_VERSION: "3.22.3"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503 RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
@@ -18,14 +17,25 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
job: job:
# Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3).
- { - {
target: x86_64-unknown-linux-gnu, target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04, os: ubuntu-22.04,
extra-build-args: "", extra-build-args: "",
flutter-version: "3.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: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
@@ -49,28 +59,28 @@ jobs:
wget wget
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.RUST_VERSION }} toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
components: "rustfmt" components: "rustfmt"
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with: with:
prefix-key: bridge-${{ matrix.job.os }} prefix-key: bridge-${{ matrix.job.os }}
- name: Cache Bridge - name: Cache Bridge
id: cache-bridge id: cache-bridge
uses: actions/cache@v3 uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
with: with:
path: /tmp/flutter_rust_bridge path: /tmp/flutter_rust_bridge
key: vcpkg-${{ matrix.job.arch }} key: bridge-${{ matrix.job.flutter-version }}
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ matrix.job.flutter-version }}
cache: true cache: true
- name: Install flutter rust bridge deps - name: Install flutter rust bridge deps
@@ -78,7 +88,15 @@ jobs:
run: | run: |
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked 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 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 - name: Run flutter rust bridge
run: | run: |
@@ -86,9 +104,9 @@ jobs:
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: bridge-artifact name: ${{ matrix.job.artifact-name }}
path: | path: |
./src/bridge_generated.rs ./src/bridge_generated.rs
./src/bridge_generated.io.rs ./src/bridge_generated.io.rs

View File

@@ -29,13 +29,13 @@ jobs:
# name: Ensure 'cargo fmt' has been run # name: Ensure 'cargo fmt' has been run
# runs-on: ubuntu-20.04 # runs-on: ubuntu-20.04
# steps: # steps:
# - uses: actions-rs/toolchain@v1 # - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
# with: # with:
# toolchain: stable # toolchain: stable
# default: true # default: true
# profile: minimal # profile: minimal
# components: rustfmt # components: rustfmt
# - uses: actions/checkout@v3 # - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
# - run: cargo fmt -- --check # - run: cargo fmt -- --check
# min_version: # min_version:
@@ -43,24 +43,24 @@ jobs:
# runs-on: ubuntu-20.04 # runs-on: ubuntu-20.04
# steps: # steps:
# - name: Checkout source code # - name: Checkout source code
# uses: actions/checkout@v3 # uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
# with: # with:
# submodules: recursive # submodules: recursive
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }}) # - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
# uses: actions-rs/toolchain@v1 # uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
# with: # with:
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }} # toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
# default: true # default: true
# profile: minimal # minimal component installation (ie, no documentation) # profile: minimal # minimal component installation (ie, no documentation)
# components: clippy # components: clippy
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix) # - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
# uses: actions-rs/cargo@v1 # uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
# with: # with:
# command: clippy # command: clippy
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints # args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
# - name: Run tests # - name: Run tests
# uses: actions-rs/cargo@v1 # uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
# with: # with:
# command: test # command: test
# args: --locked # args: --locked
@@ -81,14 +81,15 @@ jobs:
# - { target: x86_64-apple-darwin , os: macos-10.15 } # - { target: x86_64-apple-darwin , os: macos-10.15 }
# - { target: x86_64-pc-windows-gnu , os: windows-2022 } # - { target: x86_64-pc-windows-gnu , os: windows-2022 }
# - { target: x86_64-pc-windows-msvc , 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-gnu , os: ubuntu-24.04 }
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true } # - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
steps: steps:
- name: Free Disk Space (Ubuntu) - name: Free Disk Space (Ubuntu)
if: runner.os == 'Linux' if: runner.os == 'Linux'
# jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml # jlumbroso/free-disk-space@v1.3.1 is used in .github\workflows\flutter-build.yml
# But pinning to a specific version to avoid unexpected issues is preferred. # But pinning to a specific version to avoid unexpected issues is preferred.
uses: jlumbroso/free-disk-space@v1.3.1 uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with: with:
tool-cache: false tool-cache: false
android: true android: true
@@ -99,14 +100,14 @@ jobs:
swap-storage: false swap-storage: false
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
@@ -145,7 +146,7 @@ jobs:
esac esac
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgDirectory: /opt/artifacts/vcpkg vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -156,7 +157,7 @@ jobs:
shell: bash shell: bash
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: stable toolchain: stable
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
@@ -172,10 +173,10 @@ jobs:
cargo -V cargo -V
rustc -V rustc -V
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Build - name: Build
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
with: with:
use-cross: ${{ matrix.job.use-cross }} use-cross: ${{ matrix.job.use-cross }}
command: build command: build
@@ -243,7 +244,7 @@ jobs:
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
- name: Run tests - name: Run tests
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
with: with:
use-cross: ${{ matrix.job.use-cross }} use-cross: ${{ matrix.job.use-cross }}
command: test command: test

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clear cache - name: Clear cache
uses: actions/github-script@v7 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with: with:
script: | script: |
console.log("About to clear") console.log("About to clear")
@@ -30,7 +30,7 @@ jobs:
console.log("Clear completed") console.log("Clear completed")
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear - name: Purge cache # Above seems not clear thouroughly, so add this to double clear
uses: MyAlbum/purge-cache@v2 uses: MyAlbum/purge-cache@881eb5957687193fa612bf74c0042adc78ea5e54 # v2
with: with:
accessed: true # Purge caches by their last accessed time (default) accessed: true # Purge caches by their last accessed time (default)
created: false # Purge caches by their created time (default) created: false # Purge caches by their created time (default)

View File

@@ -31,7 +31,7 @@ jobs:
shell: bash shell: bash
- name: Publish RustDesk version file - name: Publish RustDesk version file
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: "fdroid-version" tag_name: "fdroid-version"

View File

@@ -27,6 +27,11 @@ env:
LLVM_VERSION: "15.0.6" LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5" FLUTTER_VERSION: "3.24.5"
ANDROID_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 # for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9" FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}" TAG_NAME: "${{ inputs.upload-tag }}"
@@ -39,7 +44,7 @@ env:
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.6" VERSION: "1.4.9"
NDK_VERSION: "r28c" NDK_VERSION: "r28c"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -53,14 +58,24 @@ jobs:
build-RustDeskTempTopMostWindow: build-RustDeskTempTopMostWindow:
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
with:
upload-artifact: ${{ inputs.upload-artifact }}
target: windows-2022
configuration: Release
platform: x64
target_version: Windows10
strategy: strategy:
fail-fast: false 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: build-for-windows-flutter:
name: ${{ matrix.job.target }} name: ${{ matrix.job.target }}
@@ -76,68 +91,134 @@ jobs:
target: x86_64-pc-windows-msvc, target: x86_64-pc-windows-msvc,
os: windows-2022, os: windows-2022,
arch: x86_64, arch: x86_64,
flutter-arch: x64,
vcpkg-triplet: x64-windows-static, 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: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Restore bridge files - name: Restore bridge files
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: 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: ./ path: ./
- name: Install LLVM and Clang - name: Install LLVM and Clang
uses: KyleMayes/install-llvm-action@v1 uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2.0.9
with: with:
version: ${{ env.LLVM_VERSION }} version: ${{ env.LLVM_VERSION }}
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277 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: with:
channel: "stable" 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 # 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 - name: Replace engine with rustdesk custom flutter engine
if: ${{ matrix.job.arch == 'x86_64' }}
run: | run: |
flutter doctor -v flutter doctor -v
flutter precache --windows flutter precache --windows
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip 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 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 - 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 shell: bash
run: | run: |
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter))) cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff [[ "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 - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.SCITER_RUST_VERSION }} toolchain: ${{ env.SCITER_RUST_VERSION }}
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
components: "rustfmt" components: "rustfmt"
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with: with:
prefix-key: ${{ matrix.job.os }} prefix-key: ${{ matrix.job.os }}
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgDirectory: C:\vcpkg vcpkgDirectory: C:\vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -163,11 +244,19 @@ jobs:
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
shell: bash 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 - name: Build rustdesk
run: | run: |
# Windows: build RustDesk # Windows: build RustDesk
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack # --hwcodec is shared by all Windows targets; per-target extras (e.g. --vram) come from the matrix
mv ./flutter/build/windows/x64/runner/Release ./rustdesk 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 # 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 Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
@@ -220,15 +309,15 @@ jobs:
fi fi
- name: Download RustDeskTempTopMostWindow artifacts - name: Download RustDeskTempTopMostWindow artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: ${{ inputs.upload-artifact }} if: ${{ inputs.upload-artifact }}
with: with:
name: topmostwindow-artifacts name: ${{ matrix.job.arch == 'aarch64' && 'topmostwindow-artifacts-ARM64' || 'topmostwindow-artifacts-x64' }}
path: "./rustdesk" path: "./rustdesk"
- name: Upload unsigned - name: Upload unsigned
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: rustdesk-unsigned-windows-${{ matrix.job.arch }} name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
path: rustdesk path: rustdesk
@@ -253,16 +342,21 @@ jobs:
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe
- name: Add MSBuild to PATH - name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2 uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
- name: Build msi - 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' if: env.UPLOAD_ARTIFACT == 'true'
run: | run: |
pushd ./res/msi pushd ./res/msi
python preprocess.py --arp -d ../../rustdesk python preprocess.py --arp -d ../../rustdesk
nuget restore msi.sln nuget restore msi.sln
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10 $msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi 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 sha256sum ../../SignOutput/rustdesk-*.msi
- name: Sign rustdesk self-extracted file - name: Sign rustdesk self-extracted file
@@ -272,7 +366,7 @@ jobs:
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
- name: Publish Release - name: Publish Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
with: with:
prerelease: true prerelease: true
@@ -302,35 +396,35 @@ jobs:
# - { target: aarch64-pc-windows-msvc, os: windows-2022 } # - { target: aarch64-pc-windows-msvc, os: windows-2022 }
steps: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Install LLVM and Clang - name: Install LLVM and Clang
uses: rustdesk-org/install-llvm-action-32bit@master uses: rustdesk-org/install-llvm-action-32bit@6aa7d9ad3df84dff01cd4596dd0fc880a7f47fce # no release tag; commit 2026-05-26
with: with:
version: ${{ env.LLVM_VERSION }} version: ${{ env.LLVM_VERSION }}
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
components: "rustfmt" components: "rustfmt"
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with: with:
prefix-key: ${{ matrix.job.os }}-sciter prefix-key: ${{ matrix.job.os }}-sciter
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgDirectory: C:\vcpkg vcpkgDirectory: C:\vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -363,7 +457,8 @@ jobs:
python3 res/inline-sciter.py python3 res/inline-sciter.py
# Patch sciter x86 # Patch sciter x86
sed -i 's/branch = "dyn"/branch = "dyn_x86"/g' ./Cargo.toml sed -i 's/branch = "dyn"/branch = "dyn_x86"/g' ./Cargo.toml
cargo build --features inline,vram,hwcodec --release --bins cargo update -p sciter-rs --precise 674e07d3066ca9a92ced3816203ab6b652629d1e
cargo build --locked --features inline,vram,hwcodec --release --bins
mkdir -p ./Release mkdir -p ./Release
mv ./target/release/rustdesk.exe ./Release/rustdesk.exe mv ./target/release/rustdesk.exe ./Release/rustdesk.exe
curl -LJ -o ./Release/sciter.dll https://github.com/c-smile/sciter-sdk/raw/master/bin.win/x32/sciter.dll curl -LJ -o ./Release/sciter.dll https://github.com/c-smile/sciter-sdk/raw/master/bin.win/x32/sciter.dll
@@ -394,7 +489,7 @@ jobs:
- name: Upload unsigned - name: Upload unsigned
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: rustdesk-unsigned-windows-${{ matrix.job.arch }} name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
path: Release path: Release
@@ -424,7 +519,7 @@ jobs:
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/ BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
- name: Publish Release - name: Publish Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
with: with:
prerelease: true prerelease: true
@@ -449,7 +544,7 @@ jobs:
} }
steps: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@@ -459,12 +554,12 @@ jobs:
run: | run: |
brew install nasm yasm brew install nasm yasm
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -475,7 +570,7 @@ jobs:
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false doNotCache: false
@@ -499,19 +594,19 @@ jobs:
shell: bash shell: bash
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.RUST_VERSION }} toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
components: "rustfmt" components: "rustfmt"
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with: with:
prefix-key: rustdesk-lib-cache-ios prefix-key: rustdesk-lib-cache-ios
key: ${{ matrix.job.target }} key: ${{ matrix.job.target }}
- name: Restore bridge files - name: Restore bridge files
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: bridge-artifact name: bridge-artifact
path: ./ path: ./
@@ -519,10 +614,10 @@ jobs:
- name: Build rustdesk lib - name: Build rustdesk lib
run: | run: |
rustup target add ${{ matrix.job.target }} rustup target add ${{ matrix.job.target }}
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
- name: Upload liblibrustdesk.a Artifacts - name: Upload liblibrustdesk.a Artifacts
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: liblibrustdesk.a name: liblibrustdesk.a
path: target/aarch64-apple-ios/release/liblibrustdesk.a path: target/aarch64-apple-ios/release/liblibrustdesk.a
@@ -537,14 +632,14 @@ jobs:
# - name: Upload Artifacts # - name: Upload Artifacts
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' # # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
# uses: actions/upload-artifact@master # uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# with: # with:
# name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk # name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
# path: flutter/build/ios/ipa/*.ipa # path: flutter/build/ios/ipa/*.ipa
# - name: Publish ipa package # - name: Publish ipa package
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' # # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
# uses: softprops/action-gh-release@v1 # uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
# with: # with:
# prerelease: true # prerelease: true
# tag_name: ${{ env.TAG_NAME }} # tag_name: ${{ env.TAG_NAME }}
@@ -577,20 +672,20 @@ jobs:
} }
steps: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Import the codesign cert - name: Import the codesign cert
if: env.MACOS_P12_BASE64 != null if: env.MACOS_P12_BASE64 != null
uses: apple-actions/import-codesign-certs@v1 uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
with: with:
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }} p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
p12-password: ${{ secrets.MACOS_P12_PASSWORD }} p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
@@ -604,7 +699,7 @@ jobs:
- name: Import notarize key - name: Import notarize key
if: env.MACOS_P12_BASE64 != null if: env.MACOS_P12_BASE64 != null
uses: timheuer/base64-to-file@v1.2 uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
with: with:
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling # https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
fileName: rustdesk.json fileName: rustdesk.json
@@ -643,7 +738,7 @@ jobs:
nasm --version nasm --version
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -662,24 +757,24 @@ jobs:
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.MAC_RUST_VERSION }} toolchain: ${{ env.MAC_RUST_VERSION }}
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
components: "rustfmt" components: "rustfmt"
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with: with:
prefix-key: ${{ matrix.job.os }} prefix-key: ${{ matrix.job.os }}
- name: Restore bridge files - name: Restore bridge files
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: bridge-artifact name: bridge-artifact
path: ./ path: ./
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false doNotCache: false
@@ -731,7 +826,7 @@ jobs:
- name: Upload unsigned macOS app - name: Upload unsigned macOS app
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: rustdesk-unsigned-macos-${{ matrix.job.arch }} name: rustdesk-unsigned-macos-${{ matrix.job.arch }}
path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg # can not upload the directory directly or tar.gz, which destroy the link structure, causing the codesign failed path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg # can not upload the directory directly or tar.gz, which destroy the link structure, causing the codesign failed
@@ -763,7 +858,7 @@ jobs:
- name: Publish DMG package - name: Publish DMG package
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -779,25 +874,25 @@ jobs:
if: ${{ inputs.upload-artifact }} if: ${{ inputs.upload-artifact }}
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: rustdesk-unsigned-macos-x86_64 name: rustdesk-unsigned-macos-x86_64
path: ./ path: ./
- name: Download Artifacts - name: Download Artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: rustdesk-unsigned-macos-aarch64 name: rustdesk-unsigned-macos-aarch64
path: ./ path: ./
- name: Download Artifacts - name: Download Artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: rustdesk-unsigned-windows-x86_64 name: rustdesk-unsigned-windows-x86_64
path: ./windows-x86_64/ path: ./windows-x86_64/
- name: Download Artifacts - name: Download Artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: rustdesk-unsigned-windows-x86 name: rustdesk-unsigned-windows-x86
path: ./windows-x86/ path: ./windows-x86/
@@ -807,7 +902,7 @@ jobs:
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86 tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
- name: Publish unsigned app - name: Publish unsigned app
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -844,7 +939,7 @@ jobs:
} }
steps: steps:
- name: Free Disk Space (Ubuntu) - name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with: with:
tool-cache: false tool-cache: false
android: false android: false
@@ -855,7 +950,7 @@ jobs:
swap-storage: false swap-storage: false
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@@ -897,12 +992,12 @@ jobs:
wget wget
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }} flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
@@ -912,14 +1007,14 @@ jobs:
cd $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
- uses: nttld/setup-ndk@v1 - uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
id: setup-ndk id: setup-ndk
with: with:
ndk-version: ${{ env.NDK_VERSION }} ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true add-to-path: true
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgDirectory: /opt/artifacts/vcpkg vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -954,18 +1049,18 @@ jobs:
shell: bash shell: bash
- name: Restore bridge files - name: Restore bridge files
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: bridge-artifact name: bridge-artifact
path: ./ path: ./
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.RUST_VERSION }} toolchain: ${{ env.RUST_VERSION }}
components: "rustfmt" components: "rustfmt"
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with: with:
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
key: ${{ matrix.job.target }} key: ${{ matrix.job.target }}
@@ -1001,7 +1096,7 @@ jobs:
esac esac
- name: Upload Rustdesk library to Artifacts - name: Upload Rustdesk library to Artifacts
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: librustdesk.so.${{ matrix.job.target }} name: librustdesk.so.${{ matrix.job.target }}
path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so
@@ -1066,7 +1161,7 @@ jobs:
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
echo Last build tool version is: $BUILD_TOOL_VERSION echo Last build tool version is: $BUILD_TOOL_VERSION
- uses: r0adkll/sign-android-release@v1 - uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
name: Sign app APK name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk id: sign-rustdesk
@@ -1082,14 +1177,14 @@ jobs:
- name: Upload Artifacts - name: Upload Artifacts
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}} path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
- name: Publish signed apk package - name: Publish signed apk package
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1098,7 +1193,7 @@ jobs:
- name: Publish unsigned apk package - name: Publish unsigned apk package
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true' if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1116,7 +1211,7 @@ jobs:
suffix: "" suffix: ""
steps: steps:
- name: Free Disk Space (Ubuntu) - name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with: with:
tool-cache: false tool-cache: false
android: false android: false
@@ -1127,7 +1222,7 @@ jobs:
swap-storage: false swap-storage: false
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@@ -1169,12 +1264,12 @@ jobs:
wget wget
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }} flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
@@ -1185,32 +1280,32 @@ jobs:
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
- name: Restore bridge files - name: Restore bridge files
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: bridge-artifact name: bridge-artifact
path: ./ path: ./
- name: Download Rustdesk library from Artifacts - name: Download Rustdesk library from Artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: librustdesk.so.aarch64-linux-android name: librustdesk.so.aarch64-linux-android
path: ./flutter/android/app/src/main/jniLibs/arm64-v8a path: ./flutter/android/app/src/main/jniLibs/arm64-v8a
- name: Download Rustdesk library from Artifacts - name: Download Rustdesk library from Artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: librustdesk.so.armv7-linux-androideabi name: librustdesk.so.armv7-linux-androideabi
path: ./flutter/android/app/src/main/jniLibs/armeabi-v7a path: ./flutter/android/app/src/main/jniLibs/armeabi-v7a
- name: Download Rustdesk library from Artifacts - name: Download Rustdesk library from Artifacts
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: librustdesk.so.x86_64-linux-android name: librustdesk.so.x86_64-linux-android
path: ./flutter/android/app/src/main/jniLibs/x86_64 path: ./flutter/android/app/src/main/jniLibs/x86_64
- name: Download Rustdesk library from Artifacts - name: Download Rustdesk library from Artifacts
if: ${{ env.reltype == 'debug' }} if: ${{ env.reltype == 'debug' }}
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: librustdesk.so.i686-linux-android name: librustdesk.so.i686-linux-android
path: ./flutter/android/app/src/main/jniLibs/x86 path: ./flutter/android/app/src/main/jniLibs/x86
@@ -1250,7 +1345,7 @@ jobs:
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
echo Last build tool version is: $BUILD_TOOL_VERSION echo Last build tool version is: $BUILD_TOOL_VERSION
- uses: r0adkll/sign-android-release@v1 - uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
name: Sign app APK name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk id: sign-rustdesk
@@ -1266,14 +1361,14 @@ jobs:
- name: Upload Artifacts - name: Upload Artifacts
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}} path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
- name: Publish signed apk package - name: Publish signed apk package
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1282,7 +1377,7 @@ jobs:
- name: Publish unsigned apk package - name: Publish unsigned apk package
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true' if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1316,7 +1411,7 @@ jobs:
} }
steps: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@@ -1334,13 +1429,13 @@ jobs:
fi fi
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Set Swap Space - name: Set Swap Space
if: ${{ matrix.job.arch == 'x86_64' }} if: ${{ matrix.job.arch == 'x86_64' }}
uses: pierotofy/set-swap-space@master uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with: with:
swap-size-gb: 12 swap-size-gb: 12
@@ -1350,7 +1445,7 @@ jobs:
free -m free -m
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
with: with:
toolchain: ${{ env.RUST_VERSION }} toolchain: ${{ env.RUST_VERSION }}
@@ -1369,14 +1464,14 @@ jobs:
- name: Restore bridge files - name: Restore bridge files
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: bridge-artifact name: bridge-artifact
path: ./ path: ./
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgDirectory: /opt/artifacts/vcpkg vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -1404,12 +1499,12 @@ jobs:
- name: Restore bridge files - name: Restore bridge files
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: bridge-artifact name: bridge-artifact
path: ./ path: ./
- uses: rustdesk-org/run-on-arch-action@amd64-support - uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk name: Build rustdesk
id: vcpkg id: vcpkg
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
@@ -1491,7 +1586,7 @@ jobs:
export JOBS="" export JOBS=""
fi fi
echo $JOBS echo $JOBS
cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
rm -rf target/release/deps target/release/build rm -rf target/release/deps target/release/build
rm -rf ~/.cargo rm -rf ~/.cargo
@@ -1583,7 +1678,7 @@ jobs:
- name: Publish debian/rpm package - name: Publish debian/rpm package
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1592,7 +1687,7 @@ jobs:
rustdesk-*.rpm rustdesk-*.rpm
- name: Upload deb - name: Upload deb
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
with: with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
@@ -1611,7 +1706,7 @@ jobs:
- name: Build archlinux package - name: Build archlinux package
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
uses: rustdesk-org/arch-makepkg-action@master uses: rustdesk-org/arch-makepkg-action@04200739ed1d0bf6f2188b6736b26a767c57a7f9 # no release tag; commit 2026-05-26
with: with:
packages: packages:
scripts: | scripts: |
@@ -1619,7 +1714,7 @@ jobs:
- name: Publish archlinux package - name: Publish archlinux package
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true' if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1657,14 +1752,14 @@ jobs:
} }
steps: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
@@ -1682,7 +1777,7 @@ jobs:
free -m free -m
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.SCITER_RUST_VERSION }} toolchain: ${{ env.SCITER_RUST_VERSION }}
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
@@ -1693,7 +1788,7 @@ jobs:
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}') RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
- uses: rustdesk-org/run-on-arch-action@amd64-support - uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk sciter binary for ${{ matrix.job.arch }} name: Build rustdesk sciter binary for ${{ matrix.job.arch }}
id: vcpkg id: vcpkg
with: with:
@@ -1821,7 +1916,7 @@ jobs:
# build rustdesk # build rustdesk
python3 ./res/inline-sciter.py python3 ./res/inline-sciter.py
export CARGO_INCREMENTAL=0 export CARGO_INCREMENTAL=0
cargo build --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1 cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
# make debian package # make debian package
mkdir -p ./Release mkdir -p ./Release
mv ./target/release/rustdesk ./Release/rustdesk mv ./target/release/rustdesk ./Release/rustdesk
@@ -1839,7 +1934,7 @@ jobs:
- name: Publish debian package - name: Publish debian package
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1847,7 +1942,7 @@ jobs:
rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
- name: Upload deb - name: Upload deb
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
with: with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
@@ -1866,12 +1961,12 @@ jobs:
- { target: aarch64-unknown-linux-gnu, arch: aarch64 } - { target: aarch64-unknown-linux-gnu, arch: aarch64 }
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Download Binary - name: Download Binary
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
path: . path: .
@@ -1889,6 +1984,7 @@ jobs:
sudo apt-get install -y libarchive-tools libfuse2 sudo apt-get install -y libarchive-tools libfuse2
# set-up appimage-builder # set-up appimage-builder
# https://github.com/AppImage/AppImageKit/issues/1395 # 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 sudo pip3 install git+https://github.com/rustdesk-org/appimage-builder.git
# run appimage-builder # run appimage-builder
pushd appimage pushd appimage
@@ -1896,7 +1992,7 @@ jobs:
- name: Publish appimage package - name: Publish appimage package
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -1939,12 +2035,12 @@ jobs:
} }
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Download Binary - name: Download Binary
uses: actions/download-artifact@master uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb
path: . path: .
@@ -1953,7 +2049,7 @@ jobs:
run: | run: |
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb flatpak/rustdesk.deb mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb flatpak/rustdesk.deb
- uses: rustdesk-org/run-on-arch-action@amd64-support - uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk flatpak package for ${{ matrix.job.arch }} name: Build rustdesk flatpak package for ${{ matrix.job.arch }}
id: flatpak id: flatpak
with: with:
@@ -1981,7 +2077,7 @@ jobs:
flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk
- name: Publish flatpak package - name: Publish flatpak package
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -2000,7 +2096,7 @@ jobs:
RELEASE_NAME: web-basic RELEASE_NAME: web-basic
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
@@ -2010,7 +2106,7 @@ jobs:
sudo apt-get install -y wget npm sudo apt-get install -y wget npm
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277 uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -2054,7 +2150,7 @@ jobs:
- name: Publish web - name: Publish web
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}

View File

@@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly" TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.6" VERSION: "1.4.9"
NDK_VERSION: "r26d" NDK_VERSION: "r26d"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -79,21 +79,21 @@ jobs:
} }
steps: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with: with:
script: | script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v3 uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
with: with:
ref: ${{ matrix.job.ref }} ref: ${{ matrix.job.ref }}
submodules: recursive submodules: recursive
- name: Import the codesign cert - name: Import the codesign cert
if: env.MACOS_P12_BASE64 != null if: env.MACOS_P12_BASE64 != null
uses: apple-actions/import-codesign-certs@v1 uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
with: with:
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }} p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
p12-password: ${{ secrets.MACOS_P12_PASSWORD }} p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
@@ -107,7 +107,7 @@ jobs:
- name: Import notarize key - name: Import notarize key
if: env.MACOS_P12_BASE64 != null if: env.MACOS_P12_BASE64 != null
uses: timheuer/base64-to-file@v1.2 uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
with: with:
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling # https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
fileName: rustdesk.json fileName: rustdesk.json
@@ -129,19 +129,19 @@ jobs:
brew install llvm create-dmg nasm pkg-config brew install llvm create-dmg nasm pkg-config
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ matrix.job.flutter }} flutter-version: ${{ matrix.job.flutter }}
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.RUST_VERSION }} toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }} targets: ${{ matrix.job.target }}
components: "rustfmt" components: "rustfmt"
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with: with:
prefix-key: ${{ matrix.job.os }} prefix-key: ${{ matrix.job.os }}
@@ -156,7 +156,7 @@ jobs:
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -165,7 +165,7 @@ jobs:
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
- name: Restore from cache and install vcpkg - name: Restore from cache and install vcpkg
uses: lukka/run-vcpkg@v7 uses: lukka/run-vcpkg@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
if: false if: false
with: with:
setupOnly: true setupOnly: true
@@ -222,7 +222,7 @@ jobs:
done done
- name: Publish DMG package - name: Publish DMG package
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -247,7 +247,7 @@ jobs:
} }
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v3 uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
with: with:
ref: ${{ matrix.job.ref }} ref: ${{ matrix.job.ref }}
submodules: recursive submodules: recursive
@@ -290,13 +290,13 @@ jobs:
wget wget
- name: Install flutter - name: Install flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: ${{ env.RUST_VERSION }} toolchain: ${{ env.RUST_VERSION }}
components: "rustfmt" components: "rustfmt"
@@ -310,14 +310,14 @@ jobs:
pushd flutter ; flutter pub get ; popd pushd flutter ; flutter pub get ; popd
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
- uses: nttld/setup-ndk@v1 - uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
id: setup-ndk id: setup-ndk
with: with:
ndk-version: ${{ env.NDK_VERSION }} ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true add-to-path: true
- name: Setup vcpkg with Github Actions binary cache - name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11 uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with: with:
vcpkgDirectory: /opt/artifacts/vcpkg vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -395,7 +395,7 @@ jobs:
mkdir -p signed-apk; pushd signed-apk mkdir -p signed-apk; pushd signed-apk
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
- uses: r0adkll/sign-android-release@v1 - uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
name: Sign app APK name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk id: sign-rustdesk
@@ -410,7 +410,7 @@ jobs:
BUILD_TOOLS_VERSION: "30.0.2" BUILD_TOOLS_VERSION: "30.0.2"
- name: Publish signed apk package - name: Publish signed apk package
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with: with:
prerelease: true prerelease: true
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}

View File

@@ -39,22 +39,21 @@ jobs:
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }} build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
steps: steps:
- name: Add MSBuild to PATH - name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2 uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
- name: Download the source code - name: Download the source code
run: | run: |
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
- name: Build the project - name: Build the project
run: | 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 }} msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
- name: Archive build artifacts - name: Archive build artifacts
uses: actions/upload-artifact@master uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ inputs.upload-artifact }} if: ${{ inputs.upload-artifact }}
with: with:
name: topmostwindow-artifacts name: topmostwindow-artifacts-${{ inputs.platform }}
path: | path: |
./${{ env.build_output_dir }}/WindowInjection.dll ./${{ env.build_output_dir }}/WindowInjection.dll

View File

@@ -60,3 +60,27 @@
* Do not refactor unrelated code. * Do not refactor unrelated code.
* Do not make formatting-only changes. * Do not make formatting-only changes.
* Keep naming/style consistent with nearby code. * Keep naming/style consistent with nearby code.
## Localization (`src/lang/*.rs`)
Each file is a `HashMap<key, translation>`. Layout:
* `template.rs` is the master list of every key. **Never edit it** as part of translation work.
* `en.rs` holds only the keys whose English display text differs from the key itself.
* Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`.
### Finding the English source for a key
When filling an empty entry, determine the source English text with this rule:
* If `key` exists in `en.rs` **with a non-empty value**, that value is the source text (look it up in `en.rs`).
* Otherwise the **key string itself is the source text** (the key is already plain English).
Then translate that source into the file's target language (infer the language from the file's existing non-empty entries / filename).
### Translation hygiene
* Only fill empty values. Never change keys, and never touch existing non-empty translations.
* Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source.
* Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc.
* Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`.

68
Cargo.lock generated
View File

@@ -292,7 +292,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]] [[package]]
name = "arboard" name = "arboard"
version = "3.4.0" version = "3.4.0"
source = "git+https://github.com/rustdesk-org/arboard#85be1218668ff218a7b170c9d424fde73e069914" source = "git+https://github.com/rustdesk-org/arboard#c7d5781f563176df9efd8df6287e823fb1b9bed5"
dependencies = [ dependencies = [
"clipboard-win", "clipboard-win",
"core-graphics 0.23.2", "core-graphics 0.23.2",
@@ -1324,7 +1324,7 @@ dependencies = [
[[package]] [[package]]
name = "clipboard-master" name = "clipboard-master"
version = "4.0.0-beta.6" 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 = [ dependencies = [
"objc", "objc",
"objc-foundation", "objc-foundation",
@@ -2329,7 +2329,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [ dependencies = [
"libloading 0.8.4", "libloading 0.7.4",
] ]
[[package]] [[package]]
@@ -2694,7 +2694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -3952,7 +3952,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]] [[package]]
name = "hwcodec" name = "hwcodec"
version = "0.7.1" version = "0.7.1"
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738" source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
dependencies = [ dependencies = [
"bindgen 0.59.2", "bindgen 0.59.2",
"cc", "cc",
@@ -4494,7 +4494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
dependencies = [ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
"windows-targets 0.52.6", "windows-targets 0.48.5",
] ]
[[package]] [[package]]
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]] [[package]]
name = "magnum-opus" name = "magnum-opus"
version = "0.4.0" 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 = [ dependencies = [
"bindgen 0.59.2", "bindgen 0.59.2",
"pkg-config", "pkg-config",
@@ -5996,8 +5996,8 @@ dependencies = [
[[package]] [[package]]
name = "parity-tokio-ipc" name = "parity-tokio-ipc"
version = "0.7.3-5" version = "0.7.3-6"
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291" source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
dependencies = [ dependencies = [
"futures", "futures",
"libc", "libc",
@@ -6588,7 +6588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556af5f5c953a2ee13f45753e581a38f9778e6551bc3ccc56d90b14628fe59d8" checksum = "556af5f5c953a2ee13f45753e581a38f9778e6551bc3ccc56d90b14628fe59d8"
dependencies = [ dependencies = [
"cfg-if 0.1.10", "cfg-if 0.1.10",
"rpassword 2.1.0", "rpassword",
"tempfile", "tempfile",
"termios 0.3.3", "termios 0.3.3",
"winapi 0.3.9", "winapi 0.3.9",
@@ -6673,7 +6673,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2 0.5.10", "socket2 0.5.10",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -6920,7 +6920,7 @@ dependencies = [
[[package]] [[package]]
name = "rdev" name = "rdev"
version = "0.5.0-2" version = "0.5.0-2"
source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8" source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
dependencies = [ dependencies = [
"cocoa 0.24.1", "cocoa 0.24.1",
"core-foundation 0.9.4", "core-foundation 0.9.4",
@@ -7152,17 +7152,6 @@ dependencies = [
"winapi 0.2.8", "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]] [[package]]
name = "rtcp" name = "rtcp"
version = "0.14.0" version = "0.14.0"
@@ -7174,16 +7163,6 @@ dependencies = [
"webrtc-util", "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]] [[package]]
name = "rtp" name = "rtp"
version = "0.14.0" version = "0.14.0"
@@ -7270,7 +7249,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk" name = "rustdesk"
version = "1.4.6" version = "1.4.9"
dependencies = [ dependencies = [
"android-wakelock", "android-wakelock",
"android_logger", "android_logger",
@@ -7283,7 +7262,6 @@ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
"chrono", "chrono",
"cidr-utils", "cidr-utils",
"clap 4.5.53",
"clipboard", "clipboard",
"clipboard-master", "clipboard-master",
"cocoa 0.24.1", "cocoa 0.24.1",
@@ -7341,7 +7319,6 @@ dependencies = [
"repng", "repng",
"reqwest", "reqwest",
"ringbuf", "ringbuf",
"rpassword 7.3.1",
"rubato", "rubato",
"runas", "runas",
"rust-pulsectl", "rust-pulsectl",
@@ -7385,7 +7362,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk-portable-packer" name = "rustdesk-portable-packer"
version = "1.4.6" version = "1.4.9"
dependencies = [ dependencies = [
"brotli", "brotli",
"dirs 5.0.1", "dirs 5.0.1",
@@ -7457,7 +7434,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.11.0", "linux-raw-sys 0.11.0",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -7514,7 +7491,7 @@ dependencies = [
"security-framework 3.5.1", "security-framework 3.5.1",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -9733,9 +9710,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-protocols-wlr" name = "wayland-protocols-wlr"
version = "0.3.3" version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
dependencies = [ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"wayland-backend", "wayland-backend",
@@ -10838,16 +10815,15 @@ dependencies = [
[[package]] [[package]]
name = "wl-clipboard-rs" name = "wl-clipboard-rs"
version = "0.9.0" version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f" checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"os_pipe", "os_pipe",
"rustix 0.38.34", "rustix 1.1.2",
"tempfile", "thiserror 2.0.17",
"thiserror 1.0.61",
"tree_magic_mini", "tree_magic_mini",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,8 @@ osx = platform.platform().startswith(
hbb_name = 'rustdesk' + ('.exe' if windows else '') hbb_name = 'rustdesk' + ('.exe' if windows else '')
exe_path = 'target/release/' + hbb_name exe_path = 'target/release/' + hbb_name
if windows: 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: elif osx:
flutter_build_dir = 'build/macos/Build/Products/Release/' flutter_build_dir = 'build/macos/Build/Products/Release/'
else: else:
@@ -172,7 +173,7 @@ def generate_build_script_for_docker():
# flutter_rust_bridge # flutter_rust_bridge
dart pub global activate ffigen --version 5.0.1 dart pub global activate ffigen --version 5.0.1
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . && popd pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . --locked && popd
pushd flutter && flutter pub get && popd pushd flutter && flutter pub get && popd
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
# install vcpkg # install vcpkg
@@ -299,7 +300,7 @@ Version: %s
Architecture: %s Architecture: %s
Maintainer: rustdesk <info@rustdesk.com> Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com Homepage: https://rustdesk.com
Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1 Recommends: libayatana-appindicator3-1
Description: A remote control software. Description: A remote control software.
@@ -317,7 +318,7 @@ def ffi_bindgen_function_refactor():
def build_flutter_deb(version, features): def build_flutter_deb(version, features):
if not skip_cargo: if not skip_cargo:
system2(f'cargo build --features {features} --lib --release') system2(f'cargo build --locked --features {features} --lib --release')
ffi_bindgen_function_refactor() ffi_bindgen_function_refactor()
os.chdir('flutter') os.chdir('flutter')
system2('flutter build linux --release') system2('flutter build linux --release')
@@ -405,12 +406,17 @@ def build_flutter_dmg(version, features):
if not skip_cargo: if not skip_cargo:
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project # set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
system2( system2(
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features {features} --release') f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --locked --features {features} --release')
# copy dylib # copy dylib
system2( system2(
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib") "cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
os.chdir('flutter') 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('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
''' '''
system2( system2(
@@ -422,7 +428,7 @@ def build_flutter_dmg(version, features):
def build_flutter_arch_manjaro(version, features): def build_flutter_arch_manjaro(version, features):
if not skip_cargo: if not skip_cargo:
system2(f'cargo build --features {features} --lib --release') system2(f'cargo build --locked --features {features} --lib --release')
ffi_bindgen_function_refactor() ffi_bindgen_function_refactor()
os.chdir('flutter') os.chdir('flutter')
system2('flutter build linux --release') system2('flutter build linux --release')
@@ -433,7 +439,7 @@ def build_flutter_arch_manjaro(version, features):
def build_flutter_windows(version, features, skip_portable_pack): def build_flutter_windows(version, features, skip_portable_pack):
if not skip_cargo: if not skip_cargo:
system2(f'cargo build --features {features} --lib --release') system2(f'cargo build --locked --features {features} --lib --release')
if not os.path.exists("target/release/librustdesk.dll"): if not os.path.exists("target/release/librustdesk.dll"):
print("cargo build failed, please check rust source code.") print("cargo build failed, please check rust source code.")
exit(-1) exit(-1)
@@ -489,13 +495,13 @@ def main():
if windows: if windows:
# build virtual display dynamic library # build virtual display dynamic library
os.chdir('libs/virtual_display/dylib') os.chdir('libs/virtual_display/dylib')
system2('cargo build --release') system2('cargo build --locked --release')
os.chdir('../../..') os.chdir('../../..')
if flutter: if flutter:
build_flutter_windows(version, features, args.skip_portable_pack) build_flutter_windows(version, features, args.skip_portable_pack)
return return
system2('cargo build --release --features ' + features) system2('cargo build --locked --release --features ' + features)
# system2('upx.exe target/release/rustdesk.exe') # system2('upx.exe target/release/rustdesk.exe')
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe') system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
pa = os.environ.get('P') pa = os.environ.get('P')
@@ -506,6 +512,7 @@ def main():
'target\\release\\rustdesk.exe') 'target\\release\\rustdesk.exe')
else: else:
print('Not signed') print('Not signed')
os.makedirs(res_dir, exist_ok=True)
system2( system2(
f'cp -rf target/release/RustDesk.exe {res_dir}') f'cp -rf target/release/RustDesk.exe {res_dir}')
os.chdir('libs/portable') os.chdir('libs/portable')
@@ -519,7 +526,7 @@ def main():
if flutter: if flutter:
build_flutter_arch_manjaro(version, features) build_flutter_arch_manjaro(version, features)
else: else:
system2('cargo build --release --features ' + features) system2('cargo build --locked --release --features ' + features)
system2('git checkout src/ui/common.tis') system2('git checkout src/ui/common.tis')
system2('strip target/release/rustdesk') system2('strip target/release/rustdesk')
system2('ln -s res/pacman_install && ln -s res/PKGBUILD') system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
@@ -528,7 +535,7 @@ def main():
version, version)) version, version))
# pacman -U ./rustdesk.pkg.tar.zst # pacman -U ./rustdesk.pkg.tar.zst
elif os.path.isfile('/usr/bin/yum'): elif os.path.isfile('/usr/bin/yum'):
system2('cargo build --release --features ' + features) system2('cargo build --locked --release --features ' + features)
system2('strip target/release/rustdesk') system2('strip target/release/rustdesk')
system2( system2(
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version) "sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
@@ -538,7 +545,7 @@ def main():
version, version)) version, version))
# yum localinstall rustdesk.rpm # yum localinstall rustdesk.rpm
elif os.path.isfile('/usr/bin/zypper'): elif os.path.isfile('/usr/bin/zypper'):
system2('cargo build --release --features ' + features) system2('cargo build --locked --release --features ' + features)
system2('strip target/release/rustdesk') system2('strip target/release/rustdesk')
system2( system2(
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version) "sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
@@ -557,7 +564,7 @@ def main():
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb') # 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
build_flutter_deb(version, features) build_flutter_deb(version, features)
else: else:
system2('cargo bundle --release --features ' + features) system2('cargo --locked bundle --release --features ' + features)
if osx: if osx:
system2( system2(
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk') 'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')

View File

@@ -1,10 +1,10 @@
<p align="center"> <p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br> <img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
<a href="#빌드를 위한 원시 단계">빌드</a> • <a href="#빌드를_위한_원시_단계">빌드</a> •
<a href="#Docker로 빌드하는 방법">Docker</a> • <a href="#Docker로_빌드하는_방법">Docker</a> •
<a href="#파일 구조">구조</a> • <a href="#파일_구조">구조</a> •
<a href="#스크린샷">스샷</a><br> <a href="#스크린샷">스샷</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>]<br> [<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b> <b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
</p> </p>
@@ -46,9 +46,9 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) [macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
## 빌드를 위한 원시 단계 ## 빌드를_위한_원시_단계
- Rust 개발 환경과 C++ 빌드 환경 준비합니다 - Rust 개발 환경과 C++ 빌드 환경 준비
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다 - [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
@@ -125,7 +125,7 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run VCPKG_ROOT=$HOME/vcpkg cargo run
``` ```
## Docker로 빌드하는 방법 ## Docker로_빌드하는_방법
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다: 먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
@@ -156,7 +156,7 @@ target/release/rustdesk
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요. RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
## 파일 구조 ## 파일_구조
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수 - **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐 - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐

View File

@@ -1,55 +1,82 @@
<p align="center"> <p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br> <img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
<a href="#servidores-públicos-grátis">Servidores</a> • <a href="#compilar">Compilar</a> •
<a href="#compilação-crua">Compilar</a> • <a href="#como-compilar-com-o-docker">Docker</a> •
<a href="#como-compilar-com-docker">Docker</a> •
<a href="#estrutura-de-arquivos">Estrutura</a> • <a href="#estrutura-de-arquivos">Estrutura</a> •
<a href="#screenshots">Screenshots</a><br> <a href="#capturas-de-tela">Capturas de Tela</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br> [<a href="../README.md">Inglês</a>] | [<a href="docs/README-UA.md">Ucraniano</a>] | [<a href="docs/README-CS.md">Tcheco</a>] | [<a href="docs/README-ZH.md">Chinês</a>] | [<a href="docs/README-HU.md">Húngaro</a>] | [<a href="docs/README-ES.md">Espanhol</a>] | [<a href="docs/README-FA.md">Persa</a>] | [<a href="docs/README-FR.md">Frans</a>] | [<a href="docs/README-DE.md">Alemão</a>] | [<a href="docs/README-PL.md">Polonês</a>] | [<a href="docs/README-ID.md">Indonésio</a>] | [<a href="docs/README-FI.md">Finlandês</a>] | [<a href="docs/README-ML.md">Malaiala</a>] | [<a href="docs/README-JP.md">Japonês</a>] | [<a href="docs/README-NL.md">Holandês</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Russo</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">Coreano</a>] | [<a href="docs/README-AR.md">Árabe</a>] | [<a href="docs/README-VN.md">Vietnamita</a>] | [<a href="docs/README-DA.md">Dinamarquês</a>] | [<a href="docs/README-GR.md">Grego</a>] | [<a href="docs/README-TR.md">Turco</a>] | [<a href="docs/README-NO.md">Norueguês</a>] | [<a href="docs/README-RO.md">Romeno</a>]<br>
<b>Precisamos de sua ajuda para traduzir este README e a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">UI do RustDesk</a> para sua língua nativa</b> <b>Precisamos da sua ajuda para traduzir este README, a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">Interface do RustDesk</a> e a <a href="https://github.com/rustdesk/doc.rustdesk.com">Documentação do RustDesk</a> para o seu idioma nativo</b>
</p> </p>
> [!Caution]
> **Aviso de Isenção de Responsabilidade por Uso Indevido:** <br>
> Os desenvolvedores do RustDesk não toleram ou apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, viola estritamente nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido do aplicativo.
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Recursos%20Avan%C3%A7ados-blue)](https://rustdesk.com/pricing.html) [![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Advanced%20Features-blue)](https://rustdesk.com/pricing.html)
Mais um software de desktop remoto, escrito em Rust. Funciona por padrão, sem necessidade de configuração. Você tem completo controle de seus dados, sem se preocupar com segurança. Você pode usar nossos servidores de rendezvous/relay, [configurar seu próprio](https://rustdesk.com/server), ou [escrever seu próprio servidor de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo). Mais uma solução de desktop remoto, escrita em Rust. Funciona imediatamente, sem necessidade de configuração. Você tem controle total dos seus dados, sem preocupações com segurança. Você pode usar nosso servidor de conexão/retransmissão (rendezvous/relay), [configurar o seu próprio](https://rustdesk.com/server) ou [escrever seu próprio servidor de conexão/retransmissão](https://github.com/rustdesk/rustdesk-server-demo).
RustDesk acolhe contribuições de todos. Leia [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ver como começar. ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
[**DOWNLOAD DE BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases) O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIBUTING.md) para ajuda em como começar.
[**Perguntas Frequentes (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
[**DOWNLOAD DOS BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
[**VERSÕES NIGHTLY (EM DESENVOLVIMENTO)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[<img src="https://f-droid.org/badge/get-it-on.png"
alt="Baixe no F-Droid"
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
[<img src="https://flathub.org/api/badge?svg&locale=en"
alt="Baixe no Flathub"
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
## Dependências ## Dependências
Versões de desktop utilizam [sciter](https://sciter.com/) para a GUI, por favor baixe a biblioteca dinâmica sciter por conta própria. As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Verifique nosso [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para instruções de compilação da versão em Flutter.
Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria.
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) [macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
## Compilação crua ## Passos básicos para compilar
- Prepare seu ambiente de desenvolvimento Rust e ambiente de compilação C++ - Prepare seu ambiente de desenvolvimento Rust e o ambiente de compilação C++
- Instale [vcpkg](https://github.com/microsoft/vcpkg), e configure a variável de ambiente `VCPKG_ROOT` corretamente - Instale o [vcpkg](https://github.com/microsoft/vcpkg) e configure a variável de ambiente `VCPKG_ROOT` corretamente
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static - Windows: `vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static`
- Linux/MacOS: vcpkg install libvpx libyuv opus aom - Linux/macOS: `vcpkg install libvpx libyuv opus aom`
- Execute `cargo run` - Execute `cargo run`
## Como compilar no Linux ## [Compilar](https://rustdesk.com/docs/en/dev/build/)
## Como Compilar no Linux
### Ubuntu 18 (Debian 10) ### Ubuntu 18 (Debian 10)
```sh ```sh
sudo apt install -y g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
``` ```
### Fedora 28 (CentOS 8) ### Fedora 28 (CentOS 8)
```sh ```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
``` ```
### Arch (Manjaro) ### Arch (Manjaro)
@@ -58,7 +85,7 @@ sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
``` ```
### Instale vcpkg ### Instalar o vcpkg
```sh ```sh
git clone https://github.com/microsoft/vcpkg git clone https://github.com/microsoft/vcpkg
@@ -70,7 +97,7 @@ export VCPKG_ROOT=$HOME/vcpkg
vcpkg/vcpkg install libvpx libyuv opus aom vcpkg/vcpkg install libvpx libyuv opus aom
``` ```
### Conserte libvpx (Para o Fedora) ### Corrigir o libvpx (Para Fedora)
```sh ```sh
cd vcpkg/buildtrees/libvpx/src cd vcpkg/buildtrees/libvpx/src
@@ -83,12 +110,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
cd cd
``` ```
### Compile ### Compilar
```sh ```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env source $HOME/.cargo/env
git clone https://github.com/rustdesk/rustdesk git clone --recurse-submodules https://github.com/rustdesk/rustdesk
cd rustdesk cd rustdesk
mkdir -p target/debug mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
@@ -96,57 +123,57 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run VCPKG_ROOT=$HOME/vcpkg cargo run
``` ```
## Como compilar com Docker ## Como compilar com o Docker
Comece clonando o repositório e montando o container docker: Comece clonando o repositório e construindo o contêiner Docker:
```sh ```sh
git clone https://github.com/rustdesk/rustdesk git clone https://github.com/rustdesk/rustdesk
cd rustdesk cd rustdesk
git submodule update --init --recursive
docker build -t "rustdesk-builder" . docker build -t "rustdesk-builder" .
``` ```
Então, sempre que precisar compilar a aplicação, execute este comando: Depois, cada vez que precisar compilar o aplicativo, execute o seguinte comando:
```sh ```sh
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
``` ```
Note que a primeira compilação pode demorar mais antes que as dependências sejam armazenadas em cache, as compilações subsequentes serão mais rápidas. Adicionalmente, se você precisar especificar argumentos diferentes para o comando de compilação, você pode fazê-lo ao final do comando na posição do `<OPTIONAL-ARGS>`. Por exemplo, se você gostaria de compilar uma versão de release otimizada, você executaria o comando acima seguido de `--release`. O executável gerado estará disponível no diretório alvo no seu sistema, e pode ser executado com: Note que a primeira compilação pode demorar mais a que as dependências sejam armazenadas em cache; as compilações subsequentes serão mais rápidas. Além disso, se você precisar especificar argumentos diferentes para o comando de compilação, pode fazê-lo ao final do comando na posição `<ARGUMENTOS-OPCIONAIS>`. Por exemplo, se você quiser compilar uma versão de lançamento (release) otimizada, executaria o comando acima seguido de `--release`. O executável resultante estará disponível na pasta `target` do seu sistema e pode ser executado com:
```sh ```sh
target/debug/rustdesk target/debug/rustdesk
``` ```
Ou, se estiver rodando um executável de release: Ou, se estiver executando o executável de lançamento:
```sh ```sh
target/release/rustdesk target/release/rustdesk
``` ```
Por favor verifique que está executando estes comandos da raiz do repositório do RustDesk, senão a aplicação pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo como `install` ou `run` não são suportados atualmente via este método, já que eles iriam instalar ou rodar o programa dentro do container ao invés do host. Certifique-se de executar esses comandos a partir da raiz do repositório do RustDesk, do contrário o aplicativo pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo, como `install` ou `run`, não são suportados atualmente por este método, pois instalariam ou executariam o programa dentro do contêiner em vez de no sistema hospedeiro.
## Estrutura de arquivos ## Estrutura de Arquivos
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configurações, wrapper de tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos, e outras funções utilitárias - **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configuração, encapsulador (wrapper) tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos e algumas outras funções utilitárias.
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela.
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico a cada plataforma - **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico de cada plataforma.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI - **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementação de copiar e colar arquivos para Windows, Linux e macOS.
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo, e conexões de rede - **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interface Sciter antiga (descontinuada).
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: iniciar uma conexão "peer to peer" - **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo e conexões de rede.
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed) - **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inicia uma conexão direta (peer connection).
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
> [!Cuidadob] ## Capturas de Tela
> **Aviso de uso indevido:** <br>
> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação.
## Screenshots ![Gerenciador de Conexões](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![Conectado a um PC Windows](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png) ![Transferência de Arquivos](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) ![Tunelamento TCP](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)
![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png)

View File

@@ -33,4 +33,4 @@ if [ -z $release ]; then
fi fi
set -f set -f
#shellcheck disable=2086 #shellcheck disable=2086
VCPKG_ROOT=/vcpkg cargo build $argv VCPKG_ROOT=/vcpkg cargo build --locked $argv

View File

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

View File

@@ -200,12 +200,13 @@ class MainActivity : FlutterActivity() {
"stop_input" -> { "stop_input" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
InputService.ctx?.disableSelf() 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) result.success(true)
} }
"cancel_notification" -> { "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

@@ -460,6 +460,7 @@ build)
--target "${RUST_TARGET}" \ --target "${RUST_TARGET}" \
--bindgen \ --bindgen \
build \ build \
--locked \
--release \ --release \
--features "${RUSTDESK_FEATURES}" --features "${RUSTDESK_FEATURES}"

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cargo build --features flutter --release --target x86_64-apple-ios --lib cargo build --locked --features flutter --release --target x86_64-apple-ios --lib

View File

@@ -716,6 +716,17 @@ closeConnection({String? id}) {
stateGlobal.isInMainPage = true; stateGlobal.isInMainPage = true;
} else { } else {
final controller = Get.find<DesktopTabController>(); final controller = Get.find<DesktopTabController>();
if (controller.tabType == DesktopTabType.terminal &&
controller.onCloseWindow != null) {
// Terminal windows are scoped to one peer. The optional id passed to
// closeConnection() is that peer id, not a terminal tab key
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
// the peer's whole terminal window, including all terminal tabs.
unawaited(controller.onCloseWindow!().catchError((e, _) {
debugPrint('[closeConnection] Failed to close terminal window: $e');
}));
return;
}
controller.closeBy(id); controller.closeBy(id);
} }
} }
@@ -1174,6 +1185,48 @@ void msgBox(SessionID sessionId, String type, String title, String text,
VoidCallback? onSubmit, VoidCallback? onSubmit,
int? submitTimeout}) { int? submitTimeout}) {
dialogManager.dismissAll(); 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 = []; List<Widget> buttons = [];
bool hasOk = false; bool hasOk = false;
submit() { submit() {
@@ -3339,7 +3392,12 @@ Future<List<Rect>> getScreenRectList() async {
} }
openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi, 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 final displays = i == kAllDisplayValue
? List.generate(pi.displays.length, (index) => index) ? List.generate(pi.displays.length, (index) => index)
: [i]; : [i];
@@ -3702,14 +3760,54 @@ Widget loadPowered(BuildContext context) {
).marginOnly(top: 6); ).marginOnly(top: 6);
} }
// max 300 x 60 const _kDefaultLogoAsset = 'assets/logo.png';
Widget loadLogo() { const _kLightLogoAsset = 'assets/logo_light.png';
return FutureBuilder<ByteData>( const _kDarkLogoAsset = 'assets/logo_dark.png';
future: rootBundle.load('assets/logo.png'),
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) { List<String> _logoAssetCandidatesForBrightness(Brightness brightness) {
if (snapshot.hasData) { 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( final image = Image.asset(
'assets/logo.png', asset,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (ctx, error, stackTrace) { errorBuilder: (ctx, error, stackTrace) {
return Container(); return Container();
@@ -3721,9 +3819,14 @@ Widget loadLogo() {
).marginOnly(left: 12, right: 12, top: 12); ).marginOnly(left: 12, right: 12, top: 12);
} }
return const Offstage(); return const Offstage();
}); },
);
}
} }
// max 300 x 60
Widget loadLogo() => const _Logo();
Widget loadIcon(double size) { Widget loadIcon(double size) {
return Image.asset('assets/icon.png', return Image.asset('assets/icon.png',
width: size, width: size,
@@ -4179,8 +4282,7 @@ Widget? buildAvatarWidget({
width: size, width: size,
height: size, height: size,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
fallback ?? SizedBox.shrink(),
), ),
); );
} }

View File

@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import '../../../models/platform_model.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.dart';
import 'package:flutter_hbb/common/widgets/peer_card.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 { class AllPeersLoader {
List<Peer> peers = []; List<Peer> peers = [];
bool _isPeersLoading = false; bool _isPeersLoading = false;
bool _isPeersLoaded = 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'; final String _listenerKey = 'AllPeersLoader';
static const String _cbQueryOnlines = 'callback_query_onlines';
late void Function(VoidCallback) setState; 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 needLoad => !_isPeersLoaded && !_isPeersLoading;
bool get isPeersLoaded => _isPeersLoaded; 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) { void init(void Function(VoidCallback) setState) {
this.setState = setState; _setState = setState;
_isCleared = false;
gFFI.recentPeersModel.addListener(_mergeAllPeers); gFFI.recentPeersModel.addListener(_mergeAllPeers);
gFFI.lanPeersModel.addListener(_mergeAllPeers); gFFI.lanPeersModel.addListener(_mergeAllPeers);
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
(evt) async {
_updateOnlineState(evt);
});
} }
void clear() { void clear() {
@@ -33,6 +145,11 @@ class AllPeersLoader {
gFFI.lanPeersModel.removeListener(_mergeAllPeers); gFFI.lanPeersModel.removeListener(_mergeAllPeers);
gFFI.abModel.removePeerUpdateListener(_listenerKey); gFFI.abModel.removePeerUpdateListener(_listenerKey);
gFFI.groupModel.removePeerUpdateListener(_listenerKey); gFFI.groupModel.removePeerUpdateListener(_listenerKey);
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
_queryOnlineTimer?.cancel();
_lastQueryOnlineOptions = const [];
_setState = null;
_isCleared = true;
} }
Future<void> getAllPeers() async { Future<void> getAllPeers() async {
@@ -59,50 +176,106 @@ class AllPeersLoader {
} }
void _mergeAllPeers() { void _mergeAllPeers() {
Map<String, dynamic> combinedPeers = {}; if (_isCleared) {
for (var p in gFFI.abModel.allPeers()) { return;
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
} }
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { peers = mergeAutocompletePeers(
if (!combinedPeers.containsKey(p.id)) { addressBookPeers: gFFI.abModel.allPeers(),
combinedPeers[p.id] = p.toJson(); groupPeers: gFFI.groupModel.peers,
} lanPeers: gFFI.lanPeersModel.peers,
} recentPeers: gFFI.recentPeersModel.peers,
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
List<Peer> parsedPeers = []; );
for (var peer in combinedPeers.values) { _applyLastOnlineState(peers);
parsedPeers.add(Peer.fromJson(peer)); _scheduleSetState(() {
}
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(() {
_isPeersLoading = false; _isPeersLoading = false;
_isPeersLoaded = true; _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 { class AutocompletePeerTile extends StatefulWidget {

View File

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

View File

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

View File

@@ -13,8 +13,84 @@ import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
bool isEditOsPassword = false; bool isEditOsPassword = false;
const String kPeerOptionAllowWaylandKeyboard = 'allow-wayland-keyboard';
const String kWaylandKeyboardIssueUrl =
'https://github.com/rustdesk/rustdesk/issues/14586';
final Set<String> _waylandKeyboardPromptSuppressedConnectionIds = <String>{};
Future<bool> openWaylandKeyboardIssueUrl() {
return launchUrl(
Uri.parse(kWaylandKeyboardIssueUrl),
mode: LaunchMode.externalApplication,
);
}
bool isWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
return _waylandKeyboardPromptSuppressedConnectionIds.contains(connectionId);
}
void setWaylandKeyboardPromptSuppressedForConnection(
String connectionId, bool suppressed) {
if (suppressed) {
_waylandKeyboardPromptSuppressedConnectionIds.add(connectionId);
} else {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
}
void clearWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
bool shouldShowWaylandKeyboardPrompt({
required String connectionId,
required bool isWaylandPeer,
required bool allowWaylandKeyboardRemembered,
}) {
return isWaylandPeer &&
!allowWaylandKeyboardRemembered &&
!isWaylandKeyboardPromptSuppressedForConnection(connectionId);
}
Widget waylandKeyboardScopeChip(BuildContext context, String text) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: colorScheme.primary.withOpacity(0.35)),
),
child: Text(
text,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
);
}
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 { class TTextMenu {
final Widget child; final Widget child;
@@ -87,12 +163,179 @@ handleOsPasswordAction(
} }
} }
void showWaylandKeyboardInputWarningDialog(
{required String id,
required String connectionId,
required FFI ffi,
required Future<void> Function() onEnable}) {
bool remember = false;
bool consentInProgress = false;
bool dialogClosed = false;
final dialogFuture = ffi.dialogManager.show((setState, close, context) {
void safeSetState(VoidCallback fn) {
if (dialogClosed) {
return;
}
try {
setState(fn);
} catch (e) {
debugPrint('Ignore setState after dialog disposal: $e');
}
}
void closeDialog() {
if (dialogClosed) {
return;
}
dialogClosed = true;
close();
}
Future<void> enableAndContinue() async {
if (consentInProgress || dialogClosed) {
return;
}
consentInProgress = true;
safeSetState(() {});
try {
await onEnable();
} catch (e, st) {
debugPrint('Failed to enable Wayland keyboard input consent: $e');
debugPrintStack(stackTrace: st);
consentInProgress = false;
safeSetState(() {});
return;
}
ffi.inputModel.keyboardInputAllowed = true;
var rememberPersisted = true;
if (remember) {
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, true));
} catch (e) {
rememberPersisted = false;
debugPrint('Failed to persist Wayland keyboard input consent: $e');
}
}
// Always suppress prompt for current connection after explicit consent.
setWaylandKeyboardPromptSuppressedForConnection(connectionId, true);
closeDialog();
if (remember && !rememberPersisted) {
// It's a rare edge case that persisting the user's choice fails.
// Failed to persist the user's choice, but still allow keyboard input for current session.
showToast(translate('Failed'));
}
}
void cancel() {
if (consentInProgress) {
return;
}
closeDialog();
}
return CustomAlertDialog(
title: null,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
msgboxContent(
'',
'wayland-keyboard-input-disabled-tip',
'wayland-keyboard-input-consent-tip',
),
SizedBox(height: isMobile ? 2 : 6),
if (isMobile) ...[
Text(
translate('wayland-keyboard-input-applies-to-tip'),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
).marginOnly(bottom: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
waylandKeyboardScopeChip(
context, translate('Send clipboard keystrokes')),
waylandKeyboardScopeChip(
context, translate('wayland-soft-keyboard-input-label')),
],
).marginOnly(bottom: 10),
],
TextButton(
onPressed: consentInProgress
? null
: () async {
try {
final opened = await openWaylandKeyboardIssueUrl();
if (!opened) {
// Opening this optional help link almost never fails in
// normal desktop environments. Keep the result handled
// for review hygiene, but avoid a low-value user toast.
debugPrint('Failed to open Wayland keyboard issue URL');
}
} catch (e) {
debugPrint(
'Failed to open Wayland keyboard issue URL: $e');
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.blue,
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
translate('Why this happens'),
style: const TextStyle(decoration: TextDecoration.underline),
),
).marginOnly(bottom: 6),
CheckboxListTile(
value: remember,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(translate('remember-wayland-keyboard-choice-tip')),
onChanged: consentInProgress
? null
: (v) {
safeSetState(() => remember = v == true);
},
),
],
),
actions: [
dialogButton(
'Cancel',
onPressed: consentInProgress ? null : cancel,
isOutline: true,
),
dialogButton(
'OK',
onPressed:
consentInProgress ? null : () => unawaited(enableAndContinue()),
),
],
onCancel: consentInProgress ? null : cancel,
onSubmit: consentInProgress ? null : () => unawaited(enableAndContinue()),
);
}, clickMaskDismiss: false, backDismiss: false);
unawaited(dialogFuture.whenComplete(() => dialogClosed = true));
}
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) { List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
final ffiModel = ffi.ffiModel; final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi; final pi = ffiModel.pi;
final perms = ffiModel.permissions; final perms = ffiModel.permissions;
final sessionId = ffi.sessionId; final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn; final isDefaultConn = ffi.connType == ConnType.defaultConn;
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
List<TTextMenu> v = []; List<TTextMenu> v = [];
// elevation // elevation
@@ -142,11 +385,60 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
v.add(TTextMenu( v.add(TTextMenu(
child: Text(translate('Send clipboard keystrokes')), child: Text(translate('Send clipboard keystrokes')),
onPressed: () async { onPressed: () async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); Future<void> sendClipboardKeystrokes() async {
if (data != null && data.text != null) { ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
bind.sessionInputString( if (data != null && data.text != null) {
sessionId: sessionId, value: data.text ?? ""); bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
}
} }
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: isWaylandPeer,
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
ffi.inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: id,
connectionId: sessionId.toString(),
ffi: ffi,
onEnable: sendClipboardKeystrokes,
);
return;
}
await sendClipboardKeystrokes();
}));
}
if (isDefaultConn &&
isWaylandPeer &&
(mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard) ||
isWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString()))) {
v.add(TTextMenu(
child: Text(translate('wayland-keyboard-input-reset-choice-tip')),
onPressed: () async {
var persistedCleared = false;
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, false));
persistedCleared = true;
} catch (e) {
debugPrint(
'Failed to clear persisted Wayland keyboard permission: $e');
} finally {
clearWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString());
ffi.inputModel.keyboardInputAllowed = false;
if (isMobile) {
await ffi.invokeMethod("enable_soft_keyboard", false);
}
}
showToast(translate(persistedCleared ? 'Successful' : 'Failed'));
})); }));
} }
// reset canvas // reset canvas
@@ -314,7 +606,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
// to-do: // to-do:
// 1. Web desktop // 1. Web desktop
// 2. Mobile, copy the image to the clipboard // 2. Mobile, copy the image to the clipboard
if (isDesktop) { if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) {
final isScreenshotSupported = bind.sessionGetCommonSync( final isScreenshotSupported = bind.sessionGetCommonSync(
sessionId: sessionId, key: 'is_screenshot_supported', param: ''); sessionId: sessionId, key: 'is_screenshot_supported', param: '');
if ('true' == isScreenshotSupported) { if ('true' == isScreenshotSupported) {
@@ -684,8 +976,10 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
child: Text(translate('Lock after session end')))); child: Text(translate('Lock after session end'))));
} }
final privacyModeState = PrivacyModeState.find(id);
if (pi.isSupportMultiDisplay && if (pi.isSupportMultiDisplay &&
PrivacyModeState.find(id).isEmpty && (privacyModeState.isEmpty ||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
pi.displaysCount.value > 1 && pi.displaysCount.value > 1 &&
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') { bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
final value = final value =
@@ -759,7 +1053,8 @@ List<TToggleMenu> toolbarPrivacyMode(
final ffiModel = ffi.ffiModel; final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi; final pi = ffiModel.pi;
final sessionId = ffi.sessionId; final sessionId = ffi.sessionId;
final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false; final hasPrivacyModePermission =
ffiModel.permissions['privacy_mode'] != false;
// Backend revocation already attempts to turn privacy mode off. // Backend revocation already attempts to turn privacy mode off.
// Still keep this menu when privacy mode is active, so users can turn it off // Still keep this menu when privacy mode is active, so users can turn it off
@@ -768,23 +1063,28 @@ List<TToggleMenu> toolbarPrivacyMode(
return []; // No permission and not active, hide options. return []; // No permission and not active, hide options.
} }
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) { bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) {
final enabled = if (!turnOn ||
!ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty); 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( return TToggleMenu(
value: privacyModeState.isNotEmpty, value: privacyModeState.isNotEmpty,
onChanged: enabled onChanged: enabled
? (value) { ? (value) {
if (value == null) return; if (value == null) return;
if (ffiModel.pi.currentDisplay != 0 && if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
ffiModel.pi.currentDisplay != kAllDisplayValue) {
msgBox(
sessionId,
'custom-nook-nocancel-hasclose',
'info',
'Please switch to Display 1 first',
'',
ffi.dialogManager);
return; return;
} }
final option = 'privacy-mode'; final option = 'privacy-mode';
@@ -802,7 +1102,7 @@ List<TToggleMenu> toolbarPrivacyMode(
getDefaultMenu((sid, opt) async { getDefaultMenu((sid, opt) async {
bind.sessionToggleOption(sessionId: sid, value: opt); bind.sessionToggleOption(sessionId: sid, value: opt);
togglePrivacyModeTime = DateTime.now(); togglePrivacyModeTime = DateTime.now();
}) }, kPrivacyModeImplMag)
]; ];
} }
if (privacyModeImpls.isEmpty) { if (privacyModeImpls.isEmpty) {
@@ -816,7 +1116,7 @@ List<TToggleMenu> toolbarPrivacyMode(
bind.sessionTogglePrivacyMode( bind.sessionTogglePrivacyMode(
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty); sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
togglePrivacyModeTime = DateTime.now(); togglePrivacyModeTime = DateTime.now();
}) }, implKey)
]; ];
} else { } else {
final visibleImpls = hasPrivacyModePermission final visibleImpls = hasPrivacyModePermission
@@ -837,6 +1137,9 @@ List<TToggleMenu> toolbarPrivacyMode(
? (value) { ? (value) {
if (value == null) return; if (value == null) return;
if (value && !hasPrivacyModePermission) return; if (value && !hasPrivacyModePermission) return;
if (!checkDisplayAllowedForPrivacyMode(implKey, value)) {
return;
}
togglePrivacyModeTime = DateTime.now(); togglePrivacyModeTime = DateTime.now();
bind.sessionTogglePrivacyMode( bind.sessionTogglePrivacyMode(
sessionId: sessionId, implKey: implKey, on: value); sessionId: sessionId, implKey: implKey, on: value);

View File

@@ -29,6 +29,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
const String kPlatformAdditionsSupportedPrivacyModeImpl = const String kPlatformAdditionsSupportedPrivacyModeImpl =
"supported_privacy_mode_impl"; "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 kPeerPlatformWindows = "Windows";
const String kPeerPlatformLinux = "Linux"; const String kPeerPlatformLinux = "Linux";
const String kPeerPlatformMacOS = "Mac OS"; const String kPeerPlatformMacOS = "Mac OS";
@@ -142,6 +146,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
const String kOptionCodecPreference = "codec-preference"; const String kOptionCodecPreference = "codec-preference";
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left"; const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right"; const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
const String kOptionAllowMultiEdgeToolbarDock =
"allow-multi-edge-toolbar-dock";
const String kOptionHideAbTagsPanel = "hideAbTagsPanel"; const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
const String kOptionRemoteMenubarState = "remoteMenubarState"; const String kOptionRemoteMenubarState = "remoteMenubarState";
const String kOptionPeerSorting = "peer-sorting"; const String kOptionPeerSorting = "peer-sorting";
@@ -166,6 +174,8 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale"; const String kOptionVirtualMouseScale = "virtual-mouse-scale";
const String kOptionShowVirtualJoystick = "show-virtual-joystick"; const String kOptionShowVirtualJoystick = "show-virtual-joystick";
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note"; 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"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
// network options // network options

View File

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

View File

@@ -407,6 +407,7 @@ class _GeneralState extends State<_General> {
final RxBool serviceStop = final RxBool serviceStop =
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service'); isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
RxBool serviceBtnEnabled = true.obs; RxBool serviceBtnEnabled = true.obs;
final GlobalKey _minToolbarOptionKey = GlobalKey();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -482,15 +483,28 @@ class _GeneralState extends State<_General> {
} }
Widget other() { Widget other() {
final incomingOnly = bind.isIncomingOnly();
final outgoingOnly = bind.isOutgoingOnly();
final showAutoUpdate = isWindows && bind.mainIsInstalled(); final showAutoUpdate = isWindows && bind.mainIsInstalled();
final children = <Widget>[ final children = <Widget>[
if (!isWeb && !bind.isIncomingOnly()) if (!isWeb && !incomingOnly)
_OptionCheckBox(context, 'Confirm before closing multiple tabs', _OptionCheckBox(context, 'Confirm before closing multiple tabs',
kOptionEnableConfirmClosingTabs, kOptionEnableConfirmClosingTabs,
isServer: false), isServer: false),
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr), if (!incomingOnly)
_OptionCheckBox(
context,
'allow-remote-toolbar-docking-any-edge',
kOptionAllowMultiEdgeToolbarDock,
isServer: false,
update: (_) {
reloadAllWindows();
},
),
if (!isWeb && !outgoingOnly)
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
if (!isWeb) wallpaper(), if (!isWeb) wallpaper(),
if (!isWeb && !bind.isIncomingOnly()) ...[ if (!isWeb && !incomingOnly) ...[
_OptionCheckBox( _OptionCheckBox(
context, context,
'Open connection in new tab', 'Open connection in new tab',
@@ -529,40 +543,40 @@ class _GeneralState extends State<_General> {
isServer: false, isServer: false,
), ),
), ),
if (!isWeb && !bind.isCustomClient()) ],
_OptionCheckBox( if (!isWeb && !bind.isCustomClient())
context, _OptionCheckBox(
'Check for software update on startup', context,
kOptionEnableCheckUpdate, 'Check for software update on startup',
isServer: false, kOptionEnableCheckUpdate,
), isServer: false,
if (showAutoUpdate) ),
_OptionCheckBox( if (showAutoUpdate)
context, _OptionCheckBox(
'Auto update', context,
kOptionAllowAutoUpdate, 'Auto update',
isServer: true, kOptionAllowAutoUpdate,
), isServer: true,
if (isWindows && !bind.isOutgoingOnly()) ),
_OptionCheckBox( if (isWindows && !outgoingOnly)
context, _OptionCheckBox(
'Capture screen using DirectX', context,
kOptionDirectxCapture, 'Capture screen using DirectX',
), kOptionDirectxCapture,
if (!bind.isIncomingOnly()) ...[ ),
_OptionCheckBox( if (!isWeb && !incomingOnly) ...[
context, _OptionCheckBox(
'Enable UDP hole punching', context,
kOptionEnableUdpPunch, 'Enable UDP hole punching',
isServer: false, kOptionEnableUdpPunch,
), isServer: false,
_OptionCheckBox( ),
context, _OptionCheckBox(
'Enable IPv6 P2P connection', context,
kOptionEnableIpv6Punch, 'Enable IPv6 P2P connection',
isServer: false, kOptionEnableIpv6Punch,
), isServer: false,
], ),
], ],
]; ];
@@ -595,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); return _Card(title: 'Other', children: children);
} }
@@ -2419,7 +2474,7 @@ class _AboutState extends State<_About> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( 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), style: const TextStyle(color: Colors.white),
), ),
Text( Text(

View File

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

View File

@@ -101,6 +101,9 @@ class _RemotePageState extends State<RemotePage>
Function(bool)? _onEnterOrLeaveImage4Toolbar; Function(bool)? _onEnterOrLeaveImage4Toolbar;
late FFI _ffi; late FFI _ffi;
Worker? _waylandKeyboardModeWorker;
bool _waylandKeyboardModeNormalized = false;
bool _waylandKeyboardModeNormalizing = false;
SessionID get sessionId => _ffi.sessionId; SessionID get sessionId => _ffi.sessionId;
@@ -178,6 +181,48 @@ class _RemotePageState extends State<RemotePage>
// Register callback to cancel debounce timer when relative mouse mode is disabled // Register callback to cancel debounce timer when relative mouse mode is disabled
_ffi.inputModel.onRelativeMouseModeDisabled = _ffi.inputModel.onRelativeMouseModeDisabled =
_cancelPointerLockCenterDebounceTimer; _cancelPointerLockCenterDebounceTimer;
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
});
if (_ffi.ffiModel.pi.isSet.value) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
}
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
if (!mounted ||
_waylandKeyboardModeNormalized ||
_waylandKeyboardModeNormalizing) {
return;
}
_waylandKeyboardModeNormalizing = true;
try {
final pi = _ffi.ffiModel.pi;
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
final mapSupported = bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: kKeyMapMode);
if (!mapSupported) return;
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
if (!mounted) return;
if (current == kKeyMapMode) {
_waylandKeyboardModeNormalized = true;
return;
}
await bind.sessionSetKeyboardMode(
sessionId: sessionId, value: kKeyMapMode);
if (!mounted) return;
await _ffi.inputModel.updateKeyboardMode();
if (!mounted) return;
_waylandKeyboardModeNormalized = true;
} catch (e, st) {
debugPrint('Failed to normalize Wayland keyboard mode: $e');
debugPrintStack(stackTrace: st);
} finally {
_waylandKeyboardModeNormalizing = false;
}
} }
/// Cancel the pointer lock center debounce timer /// Cancel the pointer lock center debounce timer
@@ -318,6 +363,7 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer?.cancel(); _pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null; _pointerLockCenterDebounceTimer = null;
_waylandKeyboardModeWorker?.dispose();
// Clear callback reference to prevent memory leaks and stale references // Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null; _ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...). // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
@@ -331,6 +377,9 @@ class _RemotePageState extends State<RemotePage>
_ffi.imageModel.disposeImage(); _ffi.imageModel.disposeImage();
_ffi.cursorModel.disposeImages(); _ffi.cursorModel.disposeImages();
_rawKeyFocusNode.dispose(); _rawKeyFocusNode.dispose();
if (closeSession) {
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
}
await _ffi.close(closeSession: closeSession); await _ffi.close(closeSession: closeSession);
_timer?.cancel(); _timer?.cancel();
_ffi.dialogManager.dismissAll(); _ffi.dialogManager.dismissAll();

View File

@@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget {
final bool? isSharedPassword; final bool? isSharedPassword;
final String? connToken; final String? connToken;
final int terminalId; final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction /// Tab key for focus management, passed from parent to avoid duplicate construction
final String tabKey; final String tabKey;
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null); final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
@@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State<TerminalPage> class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
static const EdgeInsets _defaultTerminalPadding =
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
late FFI _ffi; late FFI _ffi;
late TerminalModel _terminalModel; late TerminalModel _terminalModel;
double? _cellHeight; double? _cellHeight;
@@ -91,6 +95,13 @@ class _TerminalPageState extends State<TerminalPage>
// Register this terminal model with FFI for event routing // Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel); _ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Auto-close tab when shell exits
_terminalModel.onClosed = () {
if (mounted) {
widget.tabController.closeBy(widget.tabKey);
}
};
// Initialize terminal connection // Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id); widget.tabController.onSelected?.call(widget.id);
@@ -155,13 +166,27 @@ class _TerminalPageState extends State<TerminalPage>
// extra space left after dividing the available height by the height of a single // extra space left after dividing the available height by the height of a single
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding. // terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
EdgeInsets _calculatePadding(double heightPx) { EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) { final cellHeight = _cellHeight;
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); if (!heightPx.isFinite ||
heightPx <= 0 ||
cellHeight == null ||
!cellHeight.isFinite ||
cellHeight <= 0) {
return _defaultTerminalPadding;
}
final rows = (heightPx / cellHeight).floor();
if (rows <= 0) {
return _defaultTerminalPadding;
}
final extraSpace = heightPx - rows * cellHeight;
if (!extraSpace.isFinite || extraSpace < 0) {
return _defaultTerminalPadding;
} }
final rows = (heightPx / _cellHeight!).floor();
final extraSpace = heightPx - rows * _cellHeight!;
final topBottom = extraSpace / 2.0; final topBottom = extraSpace / 2.0;
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom); return EdgeInsets.symmetric(
horizontal: _defaultTerminalPadding.horizontal / 2,
vertical: topBottom,
);
} }
@override @override

View File

@@ -46,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
.setTitle(getWindowNameWithId(id)); .setTitle(getWindowNameWithId(id));
}; };
tabController.onRemoved = (_, id) => onRemoveId(id); tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++; final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab( tabController.add(_createTerminalTab(
peerId: params['id'], peerId: params['id'],
@@ -144,6 +145,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
_windowClosing = true; _windowClosing = true;
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList(); final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear()) // Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
// Keep the cleanup target lookup below synchronous before its first await:
// it relies on the current frame still retaining each TerminalPage's FFI/model.
tabController.clear(); tabController.clear();
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout). // Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls. // Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
@@ -368,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final persistentSessions = final persistentSessions =
args['persistent_sessions'] as List<dynamic>? ?? []; args['persistent_sessions'] as List<dynamic>? ?? [];
final sortedSessions = persistentSessions.whereType<int>().toList()..sort(); final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
var peerId = args['peer_id'] as String? ?? '';
if (peerId.isEmpty) {
if (tabController.state.value.tabs.isEmpty ||
tabController.state.value.selected >=
tabController.state.value.tabs.length) {
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
return;
}
final currentTab = tabController.state.value.selectedTabInfo;
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;
peerId = parsed.$1;
}
final existingTerminalIds = tabController.state.value.tabs
.map((tab) => _parseTabKey(tab.key))
.where((parsed) => parsed != null && parsed.$1 == peerId)
.map((parsed) => parsed!.$2)
.toSet();
if (existingTerminalIds.isEmpty) {
debugPrint(
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
return;
}
for (final terminalId in sortedSessions) { for (final terminalId in sortedSessions) {
_addNewTerminalForCurrentPeer(terminalId: terminalId); if (!existingTerminalIds.add(terminalId)) {
continue;
}
_addNewTerminal(peerId, terminalId: terminalId);
// A delay is required to ensure the UI has sufficient time to update // A delay is required to ensure the UI has sufficient time to update
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()` // before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
// may be called prematurely while the tab widget is still in the tab controller. // may be called prematurely while the tab widget is still in the tab controller.
@@ -546,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
} }
} }
Future<void> _closeWindowFromConnection() async {
await _closeAllTabs();
await WindowController.fromWindowId(windowId()).close();
}
int windowId() { int windowId() {
return widget.params["windowId"]; return widget.params["windowId"];
} }

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,7 @@ class DesktopTabController {
/// index, key /// index, key
Function(int, String)? onRemoved; Function(int, String)? onRemoved;
Function(String)? onSelected; Function(String)? onSelected;
Future<void> Function()? onCloseWindow;
DesktopTabController( DesktopTabController(
{required this.tabType, this.onRemoved, this.onSelected}); {required this.tabType, this.onRemoved, this.onSelected});
@@ -592,13 +593,13 @@ class _DesktopTabState extends State<DesktopTab>
} }
Widget _buildBar() { Widget _buildBar() {
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(
// custom double tap handler // custom double tap handler
onTap: !(bind.isIncomingOnly() && isInHomePage()) && onTap: !isIncomingHomePage && showMaximize
showMaximize
? () { ? () {
final current = DateTime.now().millisecondsSinceEpoch; final current = DateTime.now().millisecondsSinceEpoch;
final elapsed = current - _lastClickTime; final elapsed = current - _lastClickTime;
@@ -609,7 +610,7 @@ class _DesktopTabState extends State<DesktopTab>
.then((value) => stateGlobal.setMaximized(value)); .then((value) => stateGlobal.setMaximized(value));
} }
} }
: null, : (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
onPanStart: (_) => startDragging(isMainWindow), onPanStart: (_) => startDragging(isMainWindow),
onPanCancel: () { onPanCancel: () {
// We want to disable dragging of the tab area in the tab bar. // We want to disable dragging of the tab area in the tab bar.

View File

@@ -27,6 +27,7 @@ import 'common.dart';
import 'consts.dart'; import 'consts.dart';
import 'mobile/pages/home_page.dart'; import 'mobile/pages/home_page.dart';
import 'mobile/pages/server_page.dart'; import 'mobile/pages/server_page.dart';
import 'mobile/widgets/deploy_dialog.dart';
import 'models/platform_model.dart'; import 'models/platform_model.dart';
import 'package:flutter_hbb/plugin/handlers.dart' import 'package:flutter_hbb/plugin/handlers.dart'
@@ -575,6 +576,14 @@ _registerEventHandler() {
NativeUiHandler.instance.onEvent(evt); NativeUiHandler.instance.onEvent(evt);
}); });
} }
if (isAndroid) {
platformFFI.registerEventHandler(
'android_needs_deploy', 'android_needs_deploy', (_) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDeployPromptDialog();
});
});
}
} }
Widget keyListenerBuilder(BuildContext context, Widget? child) { Widget keyListenerBuilder(BuildContext context, Widget? child) {

View File

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

View File

@@ -75,6 +75,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
final FocusNode _physicalFocusNode = FocusNode(); final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard var _showEdit = false; // use soft keyboard
Worker? _waylandKeyboardGateWorker;
bool _waylandKeyboardGateInitialized = false;
InputModel get inputModel => gFFI.inputModel; InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId; SessionID get sessionId => gFFI.sessionId;
@@ -121,11 +124,33 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
isKeyboardVisible: keyboardVisibilityController.isVisible); isKeyboardVisible: keyboardVisibilityController.isVisible);
}); });
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
inputModel.keyboardInputAllowed = true;
// Wayland sessions may use clipboard-based text input on the controlled side.
// Require explicit user confirmation before allowing soft-keyboard and
// clipboard-assisted text input. Physical keyboard events are not gated here.
_waylandKeyboardGateWorker = ever(gFFI.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
_initWaylandKeyboardGateIfNeeded();
}
});
if (gFFI.ffiModel.pi.isSet.value) {
_initWaylandKeyboardGateIfNeeded();
}
} }
@override @override
Future<void> dispose() async { Future<void> dispose() async {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
// Close the session up-front. `gFFI.close()` below only calls `sessionClose`
// after several awaits (canvas save, image update, the `enable_soft_keyboard`
// platform call), so if the app is backgrounded while this page is disposing,
// dispose can be suspended before reaching it and the connection is never torn
// down. The reconnect then re-attaches to the leaked session and is stuck on
// "Connecting...". Dispatching it here makes teardown happen synchronously on
// pop; the `sessionClose` in `gFFI.close()` becomes a no-op once removed.
unawaited(bind.sessionClose(sessionId: sessionId));
// https://github.com/flutter/flutter/issues/64935 // https://github.com/flutter/flutter/issues/64935
super.dispose(); super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(store: false); gFFI.dialogManager.hideMobileActionsOverlay(store: false);
@@ -135,6 +160,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
await gFFI.invokeMethod("enable_soft_keyboard", true); await gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.dispose(); _mobileFocusNode.dispose();
_physicalFocusNode.dispose(); _physicalFocusNode.dispose();
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
_waylandKeyboardGateWorker?.dispose();
inputModel.keyboardInputAllowed = true;
await gFFI.close(); await gFFI.close();
_timer?.cancel(); _timer?.cancel();
_iosKeyboardWorkaroundTimer?.cancel(); _iosKeyboardWorkaroundTimer?.cancel();
@@ -163,6 +191,40 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.invokeMethod("try_sync_clipboard"); gFFI.invokeMethod("try_sync_clipboard");
} }
bool _shouldGateKeyboardForWayland() {
if (!(isAndroid || isIOS)) return false;
final pi = gFFI.ffiModel.pi;
return pi.platform == kPeerPlatformLinux && pi.isWayland;
}
void _initWaylandKeyboardGateIfNeeded() {
if (!mounted) return;
if (_waylandKeyboardGateInitialized) return;
if (!_shouldGateKeyboardForWayland()) return;
_waylandKeyboardGateInitialized = true;
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (!shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = true;
return;
}
inputModel.keyboardInputAllowed = false;
// Ensure soft keyboard is not active before user confirms.
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_mobileFocusNode.unfocus();
_physicalFocusNode.requestFocus();
setState(() {});
}
// to-do: It should be better to use transparent color instead of the bgColor. // to-do: It should be better to use transparent color instead of the bgColor.
// But for now, the transparent color will cause the canvas to be white. // But for now, the transparent color will cause the canvas to be white.
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay. // I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
@@ -294,7 +356,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
content == '【】')) { content == '【】')) {
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input // can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
bind.sessionInputString(sessionId: sessionId, value: content); bind.sessionInputString(sessionId: sessionId, value: content);
openKeyboard(); _openKeyboardUnlocked();
return; return;
} }
bind.sessionInputString(sessionId: sessionId, value: content); bind.sessionInputString(sessionId: sessionId, value: content);
@@ -306,6 +368,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
// handle mobile virtual keyboard // handle mobile virtual keyboard
void handleSoftKeyboardInput(String newValue) { void handleSoftKeyboardInput(String newValue) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (isIOS) { if (isIOS) {
_handleIOSSoftKeyboardInput(newValue); _handleIOSSoftKeyboardInput(newValue);
} else { } else {
@@ -314,6 +379,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
} }
void inputChar(String char) { void inputChar(String char) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (char == '\n') { if (char == '\n') {
char = 'VK_RETURN'; char = 'VK_RETURN';
} else if (char == ' ') { } else if (char == ' ') {
@@ -323,6 +391,29 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
} }
void openKeyboard() { void openKeyboard() {
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: widget.id,
connectionId: sessionId.toString(),
ffi: gFFI,
onEnable: () async {
_openKeyboardUnlocked();
},
);
return;
}
_openKeyboardUnlocked();
}
void _openKeyboardUnlocked() {
inputModel.keyboardInputAllowed = true;
gFFI.invokeMethod("enable_soft_keyboard", true); gFFI.invokeMethod("enable_soft_keyboard", true);
// destroy first, so that our _value trick can work // destroy first, so that our _value trick can work
_value = initText; _value = initText;
@@ -426,10 +517,12 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
} }
return Container( return Container(
color: MyTheme.canvasColor, color: MyTheme.canvasColor,
child: RawTouchGestureDetectorRegion( child: inputModel.isPhysicalMouse.value
child: getBodyForMobile(), ? getBodyForMobile()
ffi: gFFI, : RawTouchGestureDetectorRegion(
), child: getBodyForMobile(),
ffi: gFFI,
),
); );
}), }),
), ),
@@ -1127,7 +1220,11 @@ void showOptions(
if (image != null) { if (image != null) {
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image)); 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 cur = pi.currentDisplay;
final children = <Widget>[]; final children = <Widget>[];
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark; final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
@@ -1181,8 +1278,6 @@ void showOptions(
await toolbarDisplayToggle(context, id, gFFI); await toolbarDisplayToggle(context, id, gFFI);
List<TToggleMenu> privacyModeList = []; List<TToggleMenu> privacyModeList = [];
// privacy mode
final privacyModeState = PrivacyModeState.find(id);
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) || if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
privacyModeState.isNotEmpty) { privacyModeState.isNotEmpty) {
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI); privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);

View File

@@ -17,6 +17,7 @@ import '../../common/widgets/login.dart';
import '../../consts.dart'; import '../../consts.dart';
import '../../models/model.dart'; import '../../models/model.dart';
import '../../models/platform_model.dart'; import '../../models/platform_model.dart';
import '../widgets/deploy_dialog.dart';
import '../widgets/dialog.dart'; import '../widgets/dialog.dart';
import 'home_page.dart'; import 'home_page.dart';
import 'scan_page.dart'; import 'scan_page.dart';
@@ -728,6 +729,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onPressed: (context) { onPressed: (context) {
changeSocks5Proxy(); changeSocks5Proxy();
}), }),
if (isAndroid && !bind.isOutgoingOnly())
SettingsTile(
title: Text(translate('Deploy')),
leading: Icon(Icons.cloud_upload),
onPressed: (context) {
showDeployDialog();
}),
if (!disabledSettings && !_hideNetwork && !_hideWebSocket) if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
SettingsTile.switchTile( SettingsTile.switchTile(
title: Text(translate('Use WebSocket')), title: Text(translate('Use WebSocket')),

View File

@@ -83,6 +83,13 @@ class _TerminalPageState extends State<TerminalPage>
// Register this terminal model with FFI for event routing // Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel); _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 // Web desktop users have full hardware keyboard access, so the on-screen
// terminal extra keys bar is unnecessary and disabled. // terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop && _showTerminalExtraKeys = !isWebDesktop &&

View File

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

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
const _deployDialogTag = 'android-deploy-device';
void showDeployPromptDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
gFFI.dialogManager.show<bool>((setState, close, context) {
submit() => close(true);
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Text(translate("server_requires_deployment_tip")),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
}, tag: _deployDialogTag).then((deploy) {
if (deploy == true) {
showDeployDialog();
}
});
}
void showDeployDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
final tokenController = TextEditingController();
final idController = TextEditingController();
var errorText = "";
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
if (isInProgress) return;
final token = tokenController.text.trim();
if (token.isEmpty) {
setState(() {
errorText = translate("token is required!");
});
return;
}
setState(() {
errorText = "";
isInProgress = true;
});
String res;
try {
res = await bind.mainDeployDevice(
token: token, id: idController.text.trim());
} catch (e) {
setState(() {
errorText = translate(e.toString());
isInProgress = false;
});
return;
}
if (res.isEmpty) {
close();
await gFFI.serverModel.fetchID();
showToast(translate("Successful"));
} else {
setState(() {
errorText = translate(res.toString());
isInProgress = false;
});
}
}
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: tokenController,
decoration: InputDecoration(labelText: translate("API Token")),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofocus: true,
).workaroundFreezeLinuxMint(),
TextField(
controller: idController,
decoration:
InputDecoration(labelText: translate("Custom ID (optional)")),
).workaroundFreezeLinuxMint(),
if (errorText.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
errorText,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
).paddingOnly(top: 8),
),
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
],
),
actions: [
dialogButton("Cancel",
onPressed: isInProgress ? null : close, isOutline: true),
dialogButton("OK", onPressed: isInProgress ? null : submit),
],
onSubmit: submit,
onCancel: isInProgress ? null : close,
);
}, tag: _deployDialogTag);
}

View File

@@ -117,13 +117,13 @@ void showServerSettingsWithValue(
), ),
SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: TextFormField( child: serverSettingsTextFormField(
label: label,
controller: controller, controller: controller,
decoration: InputDecoration( errorMsg: errorMsg,
errorText: errorMsg.isEmpty ? null : errorMsg, contentPadding:
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 12),
EdgeInsets.symmetric(horizontal: 8, vertical: 12), showLabelText: false,
),
validator: validator, validator: validator,
autofocus: autofocus, autofocus: autofocus,
).workaroundFreezeLinuxMint(), ).workaroundFreezeLinuxMint(),
@@ -132,12 +132,10 @@ void showServerSettingsWithValue(
); );
} }
return TextFormField( return serverSettingsTextFormField(
label: label,
controller: controller, controller: controller,
decoration: InputDecoration( errorMsg: errorMsg,
labelText: label,
errorText: errorMsg.isEmpty ? null : errorMsg,
),
validator: validator, validator: validator,
).workaroundFreezeLinuxMint(); ).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( void setPrivacyModeDialog(
OverlayDialogManager dialogManager, OverlayDialogManager dialogManager,
List<TToggleMenu> privacyModeList, List<TToggleMenu> privacyModeList,

View File

@@ -142,12 +142,22 @@ class FileModel {
} }
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async { 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( evtLoop.pushEvent(
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt)); _FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
} }
Future<void> overrideFileConfirm(Map<String, dynamic> evt, Future<void> overrideFileConfirm(Map<String, dynamic> evt,
{bool? overrideConfirm, bool skip = false}) async { {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. // 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, // Because `resp` may be null after the user operation or the last remembered operation,
// and we should distinguish them. // and we should distinguish them.
@@ -156,15 +166,12 @@ class FileModel {
? await showFileConfirmDialog(translate("Overwrite"), ? await showFileConfirmDialog(translate("Overwrite"),
"${evt['read_path']}", true, evt['is_identical'] == "true") "${evt['read_path']}", true, evt['is_identical'] == "true")
: null); : 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) { if (false == resp) {
final jobIndex = jobController.getJob(id); await jobController.cancelTransferConflictBatch(id);
if (jobIndex != -1) {
await jobController.cancelJob(id);
final job = jobController.jobTable[jobIndex];
job.state = JobState.done;
jobController.jobTable.refresh();
}
} else { } else {
var need_override = false; var need_override = false;
if (resp == null) { if (resp == null) {
@@ -176,6 +183,7 @@ class FileModel {
} }
// Update the loop config. // Update the loop config.
if (fileConfirmCheckboxRemember) { if (fileConfirmCheckboxRemember) {
jobController.rememberTransferConflictBatch(id, resp);
evtLoop.setSkip(!need_override); evtLoop.setSkip(!need_override);
} }
await bind.sessionSetConfirmOverrideFile( await bind.sessionSetConfirmOverrideFile(
@@ -285,6 +293,8 @@ class FileModel {
final isWindows = otherSideData.options.isWindows; final isWindows = otherSideData.options.isWindows;
final showHidden = otherSideData.options.showHidden; final showHidden = otherSideData.options.showHidden;
final jobID = jobController.addTransferJob(entry, false); final jobID = jobController.addTransferJob(entry, false);
jobController.registerTransferConflictBatch([jobID],
batchId: int.tryParse(obj['batchId']?.toString() ?? ''));
webSendLocalFiles( webSendLocalFiles(
handleIndex: handleIndex, handleIndex: handleIndex,
actId: jobID, actId: jobID,
@@ -391,14 +401,30 @@ class FileController {
await Future.delayed(Duration(milliseconds: 100)); await Future.delayed(Duration(milliseconds: 100));
final dir = (await bind.sessionGetPeerOption( final savedDir = (await bind.sessionGetPeerOption(
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir")); sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
openDirectory(dir.isEmpty ? options.value.home : dir); Future<bool> tryOpenReadyDirs() async {
final dirs = <String>{
if (directory.value.path.isNotEmpty) directory.value.path,
if (savedDir.isNotEmpty) savedDir,
options.value.home,
};
for (final dir in dirs) {
if (await _openDirectoryPath(dir, isBack: true)) {
return true;
}
}
return false;
}
var opened = await tryOpenReadyDirs();
await Future.delayed(Duration(seconds: 1)); await Future.delayed(Duration(seconds: 1));
if (directory.value.path.isEmpty) { if (!opened) {
openDirectory(options.value.home); // The peer may become ready during the reconnect delay, so retry the
// same candidates instead of only retrying the default home directory.
await tryOpenReadyDirs();
} }
} }
@@ -429,19 +455,23 @@ class FileController {
}); });
} }
Future<void> refresh() async { Future<bool> refresh() async {
await openDirectory(directory.value.path); // "." can be both a refresh command and a real remote directory path.
// Refresh must bypass openDirectory's command dispatch to avoid recursion.
return await _openDirectoryPath(directory.value.path, isBack: true);
} }
Future<void> openDirectory(String path, {bool isBack = false}) async { Future<bool> openDirectory(String path, {bool isBack = false}) async {
if (path == ".") { if (!isBack && path == ".") {
refresh(); return await refresh();
return;
} }
if (path == "..") { if (!isBack && path == "..") {
goToParentDirectory(); return await _goToParentDirectory(isBack: isBack);
return;
} }
return await _openDirectoryPath(path, isBack: isBack);
}
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
if (!isBack) { if (!isBack) {
pushHistory(); pushHistory();
} }
@@ -458,8 +488,10 @@ class FileController {
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden); final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
fd.format(isWindows, sort: sortBy.value); fd.format(isWindows, sort: sortBy.value);
directory.value = fd; directory.value = fd;
return true;
} catch (e) { } catch (e) {
debugPrint("Failed to openDirectory $path: $e"); debugPrint("Failed to openDirectory $path: $e");
return false;
} }
} }
@@ -487,19 +519,22 @@ class FileController {
goBack(); goBack();
return; return;
} }
openDirectory(path, isBack: true); unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
} }
void goToParentDirectory() { void goToParentDirectory() {
unawaited(_goToParentDirectory().then<void>((_) {}));
}
Future<bool> _goToParentDirectory({bool isBack = false}) async {
final isWindows = options.value.isWindows; final isWindows = options.value.isWindows;
final dirPath = directory.value.path; final dirPath = directory.value.path;
var parent = PathUtil.dirname(dirPath, isWindows); var parent = PathUtil.dirname(dirPath, isWindows);
// specially for C:\, D:\, goto '/' // specially for C:\, D:\, goto '/'
if (parent == dirPath && isWindows) { if (parent == dirPath && isWindows) {
openDirectory('/'); return await _openDirectoryPath('/', isBack: isBack);
return;
} }
openDirectory(parent); return await _openDirectoryPath(parent, isBack: isBack);
} }
// TODO deprecated this // TODO deprecated this
@@ -545,8 +580,15 @@ class FileController {
final toPath = otherSideData.directory.path; final toPath = otherSideData.directory.path;
final isWindows = otherSideData.options.isWindows; final isWindows = otherSideData.options.isWindows;
final showHidden = otherSideData.options.showHidden; final showHidden = otherSideData.options.showHidden;
final transferJobs = <(Entry, int)>[];
final transferJobIds = <int>[];
for (var from in items.items) { for (var from in items.items) {
final jobID = jobController.addTransferJob(from, isRemoteToLocal); final jobID = jobController.addTransferJob(from, isRemoteToLocal);
transferJobs.add((from, jobID));
transferJobIds.add(jobID);
}
jobController.registerTransferConflictBatch(transferJobIds);
for (final (from, jobID) in transferJobs) {
bind.sessionSendFiles( bind.sessionSendFiles(
sessionId: sessionId, sessionId: sessionId,
actId: jobID, actId: jobID,
@@ -892,6 +934,10 @@ class JobController {
static final JobID jobID = JobID(); static final JobID jobID = JobID();
final jobTable = List<JobProgress>.empty(growable: true).obs; final jobTable = List<JobProgress>.empty(growable: true).obs;
final jobResultListener = JobResultListener<Map<String, dynamic>>(); final jobResultListener = JobResultListener<Map<String, dynamic>>();
int _nextTransferConflictBatchId = 1;
final Map<int, int> _transferConflictJobToBatch = {};
int? _transferConflictRememberBatchId;
bool? _transferConflictRememberOverrideConfirm;
final GetSessionID getSessionID; final GetSessionID getSessionID;
final GetDialogManager getDialogManager; final GetDialogManager getDialogManager;
SessionID get sessionId => getSessionID(); SessionID get sessionId => getSessionID();
@@ -904,6 +950,57 @@ class JobController {
return jobTable.indexWhere((element) => element.id == id); 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 // return jobID
int addTransferJob(Entry from, bool isRemoteToLocal) { int addTransferJob(Entry from, bool isRemoteToLocal) {
final jobID = JobController.jobID.next(); final jobID = JobController.jobID.next();
@@ -975,7 +1072,10 @@ class JobController {
id = int.parse(evt['id']); id = int.parse(evt['id']);
} catch (_) {} } catch (_) {}
final jobIndex = getJob(id); final jobIndex = getJob(id);
if (jobIndex == -1) return true; if (jobIndex == -1) {
unregisterTransferConflictJob(id);
return true;
}
final job = jobTable[jobIndex]; final job = jobTable[jobIndex];
job.recvJobRes = true; job.recvJobRes = true;
if (job.type == JobType.deleteFile) { if (job.type == JobType.deleteFile) {
@@ -1001,6 +1101,9 @@ class JobController {
job.state = JobState.done; job.state = JobState.done;
} }
jobTable.refresh(); jobTable.refresh();
if (job.state == JobState.done || job.state == JobState.error) {
unregisterTransferConflictJob(id);
}
if (job.type == JobType.deleteDir) { if (job.type == JobType.deleteDir) {
return job.state == JobState.done; return job.state == JobState.done;
} else { } else {
@@ -1010,9 +1113,15 @@ class JobController {
void jobError(Map<String, dynamic> evt) { void jobError(Map<String, dynamic> evt) {
final err = evt['err'].toString(); 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) { if (jobIndex != -1) {
final job = jobTable[jobIndex]; final job = jobTable[jobIndex];
if (job.state == JobState.done && job.err == "cancel") return;
job.state = JobState.error; job.state = JobState.error;
job.err = err; job.err = err;
job.recvJobRes = true; job.recvJobRes = true;
@@ -1035,6 +1144,11 @@ class JobController {
} }
} }
jobTable.refresh(); jobTable.refresh();
if (job.state == JobState.done || job.state == JobState.error) {
unregisterTransferConflictJob(job.id);
}
} else {
unregisterTransferConflictJob(id);
} }
if (err == _kOneWayFileTransferError) { if (err == _kOneWayFileTransferError) {
if (DateTime.now().millisecondsSinceEpoch - _lastTimeShowMsgbox > 3000) { if (DateTime.now().millisecondsSinceEpoch - _lastTimeShowMsgbox > 3000) {
@@ -1071,9 +1185,42 @@ class JobController {
} }
Future<void> cancelJob(int id) async { Future<void> cancelJob(int id) async {
unregisterTransferConflictJob(id);
await bind.sessionCancelJob(sessionId: sessionId, actId: 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 { Future<void> loadLastJob(Map<String, dynamic> evt) async {
debugPrint("load last job: $evt"); debugPrint("load last job: $evt");
Map<String, dynamic> jobDetail = json.decode(evt['value']); Map<String, dynamic> jobDetail = json.decode(evt['value']);
@@ -1120,7 +1267,7 @@ class JobController {
..state = JobState.paused; ..state = JobState.paused;
jobTable.add(jobProgress); jobTable.add(jobProgress);
} }
registerTransferConflictBatch([currJobId]);
await bind.sessionAddJob( await bind.sessionAddJob(
sessionId: sessionId, sessionId: sessionId,
isRemote: isRemote, isRemote: isRemote,
@@ -1168,6 +1315,9 @@ class JobController {
void clear() { void clear() {
jobTable.clear(); jobTable.clear();
_transferConflictJobToBatch.clear();
_transferConflictRememberBatchId = null;
_transferConflictRememberOverrideConfirm = null;
jobResultListener.clear(); jobResultListener.clear();
} }
} }
@@ -1510,6 +1660,9 @@ class JobProgress {
String display() { String display() {
if (type == JobType.transfer) { if (type == JobType.transfer) {
if (state == JobState.done && err == "cancel") {
return translate("Cancel");
}
if (state == JobState.done && err == "skipped") { if (state == JobState.done && err == "skipped") {
return translate("Skipped"); return translate("Skipped");
} }
@@ -1819,21 +1972,44 @@ class _FileDialogEvent extends BaseEvent<FileDialogType, Map<String, dynamic>> {
class FileDialogEventLoop class FileDialogEventLoop
extends BaseEventLoop<FileDialogType, Map<String, dynamic>> { extends BaseEventLoop<FileDialogType, Map<String, dynamic>> {
int? _batchId;
bool? _overrideConfirm; bool? _overrideConfirm;
bool _skip = false; bool _skip = false;
@override @override
Future<void> onPreConsume( Future<void> onPreConsume(
BaseEvent<FileDialogType, Map<String, dynamic>> evt) async { 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.setOverrideConfirm(_overrideConfirm);
event.setSkip(_skip); event.setSkip(_skip);
debugPrint( debugPrint(
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} overrideConfirm: $_overrideConfirm, skip: $_skip>"); "FileDialogEventLoop: consuming<jobId: ${evt.data['id']} batchId: $_batchId overrideConfirm: $_overrideConfirm, skip: $_skip>");
} }
@override @override
Future<void> onEventsClear() { Future<void> onEventsClear() {
_batchId = null;
_overrideConfirm = null; _overrideConfirm = null;
_skip = false; _skip = false;
return super.onEventsClear(); return super.onEventsClear();

View File

@@ -346,7 +346,7 @@ class InputModel {
/// which runs per-engine, so each isolate registers its own handler tied /// which runs per-engine, so each isolate registers its own handler tied
/// to its own set of InputModels. /// to its own set of InputModels.
static void initSideButtonChannel() { static void initSideButtonChannel() {
if (!Platform.isLinux) return; if (!isLinux) return;
if (_sideButtonChannelInitialized) return; if (_sideButtonChannelInitialized) return;
_sideButtonChannelInitialized = true; _sideButtonChannelInitialized = true;
@@ -474,6 +474,10 @@ class InputModel {
late final SessionID sessionId; late final SessionID sessionId;
// Local gate for clipboard-assisted input flows on mobile Wayland dialogs.
// It should not block physical keyboard events.
bool keyboardInputAllowed = true;
bool get keyboardPerm => parent.target!.ffiModel.keyboard; bool get keyboardPerm => parent.target!.ffiModel.keyboard;
String get id => parent.target?.id ?? ''; String get id => parent.target?.id ?? '';
String? get peerPlatform => parent.target?.ffiModel.pi.platform; String? get peerPlatform => parent.target?.ffiModel.pi.platform;
@@ -1303,7 +1307,8 @@ class InputModel {
} }
if (isPhysicalMouse.value) { if (isPhysicalMouse.value) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) { if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
edgeScroll: useEdgeScroll); edgeScroll: useEdgeScroll);
} }
} }
@@ -1544,7 +1549,8 @@ class InputModel {
_relativeMouse _relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown)); .sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
} else { } else {
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position); final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventDown), canvasPosition);
} }
} }
} }
@@ -1566,7 +1572,8 @@ class InputModel {
_relativeMouse _relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp)); .sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
} else { } else {
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position); final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventUp), canvasPosition);
} }
} }
} }
@@ -1588,12 +1595,40 @@ class InputModel {
} }
if (isPhysicalMouse.value) { if (isPhysicalMouse.value) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) { if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
edgeScroll: useEdgeScroll); 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( static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
List<RemoteWindowCoords> remoteWindowCoords) async { List<RemoteWindowCoords> remoteWindowCoords) async {
final coords = 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 HandleMsgBox = Function(Map<String, dynamic> evt, String id);
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool); typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
final _constSessionId = Uuid().v4obj(); final _constSessionId = Uuid().v4obj();
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
const _restartReconnectSilentDelaySecs = 5;
class CachedPeerData { class CachedPeerData {
Map<String, dynamic> updatePrivacyMode = {}; Map<String, dynamic> updatePrivacyMode = {};
@@ -110,6 +112,9 @@ class CachedPeerData {
class FfiModel with ChangeNotifier { class FfiModel with ChangeNotifier {
CachedPeerData cachedPeerData = CachedPeerData(); CachedPeerData cachedPeerData = CachedPeerData();
PeerInfo _pi = PeerInfo(); PeerInfo _pi = PeerInfo();
int? lastUserDisplay;
int? pendingMonitorRestore;
Timer? _pendingRestoreTimer;
Rect? _rect; Rect? _rect;
var _inputBlocked = false; var _inputBlocked = false;
@@ -119,6 +124,7 @@ class FfiModel with ChangeNotifier {
bool _touchMode = false; bool _touchMode = false;
late VirtualMouseMode virtualMouseMode; late VirtualMouseMode virtualMouseMode;
Timer? _timer; Timer? _timer;
Timer? _restartReconnectDelayTimer;
var _reconnects = 1; var _reconnects = 1;
DateTime? _offlineReconnectStartTime; DateTime? _offlineReconnectStartTime;
bool _viewOnly = false; bool _viewOnly = false;
@@ -245,11 +251,14 @@ class FfiModel with ChangeNotifier {
clear() { clear() {
_pi = PeerInfo(); _pi = PeerInfo();
lastUserDisplay = null;
_cancelPendingMonitorRestore();
_secure = null; _secure = null;
_direct = null; _direct = null;
_inputBlocked = false; _inputBlocked = false;
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
resetRestartReconnectState();
clearPermissions(); clearPermissions();
waitForImageTimer?.cancel(); waitForImageTimer?.cancel();
timerScreenshot?.cancel(); timerScreenshot?.cancel();
@@ -341,6 +350,7 @@ class FfiModel with ChangeNotifier {
} else if (name == 'connection_ready') { } else if (name == 'connection_ready') {
setConnectionType(peerId, evt['secure'] == 'true', setConnectionType(peerId, evt['secure'] == 'true',
evt['direct'] == 'true', evt['stream_type'] ?? ''); evt['direct'] == 'true', evt['stream_type'] ?? '');
resetRestartReconnectState();
} else if (name == 'switch_display') { } else if (name == 'switch_display') {
// switch display is kept for backward compatibility // switch display is kept for backward compatibility
handleSwitchDisplay(evt, sessionId, peerId); handleSwitchDisplay(evt, sessionId, peerId);
@@ -922,8 +932,29 @@ class FfiModel with ChangeNotifier {
enterUserLoginAndPasswordDialog( enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false); sessionId, dialogManager, 'terminal-admin-login-tip', false);
} else if (type == 'restarting') { } else if (type == 'restarting') {
showMsgBox(sessionId, type, title, text, link, false, dialogManager, // Treat restart messages as reconnect control events. Rust still sends
hasCancel: false); // 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') { } else if (type == 'wait-remote-accept-nook') {
showWaitAcceptDialog(sessionId, type, title, text, dialogManager); showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
} else if (type == 'on-uac' || type == 'on-foreground-elevated') { } 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. /// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise. /// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline( bool shouldAutoRetryOnOffline(
@@ -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, void reconnect(OverlayDialogManager dialogManager, SessionID sessionId,
bool forceRelay) { bool forceRelay) {
// Disable relative mouse mode before reconnecting to ensure cursor is released. // Disable relative mouse mode before reconnecting to ensure cursor is released.
parent.target?.inputModel.setRelativeMouseMode(false); parent.target?.inputModel.setRelativeMouseMode(false);
_cancelPendingMonitorRestore();
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay); bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
clearPermissions(); clearPermissions();
dialogManager.dismissAll(); dialogManager.dismissAll();
@@ -1371,9 +1419,29 @@ class FfiModel with ChangeNotifier {
// now replaced to _updateCurDisplay // now replaced to _updateCurDisplay
updateCurDisplay(sessionId); 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) { if (displays.isNotEmpty) {
_reconnects = 1; _reconnects = 1;
_offlineReconnectStartTime = null; _offlineReconnectStartTime = null;
resetRestartReconnectState();
waitForFirstImage.value = true; waitForFirstImage.value = true;
isRefreshing = false; isRefreshing = false;
} }
@@ -3666,6 +3734,7 @@ class FFI {
/// Mobile reuse FFI /// Mobile reuse FFI
void mobileReset() { void mobileReset() {
ffiModel.resetRestartReconnectState();
ffiModel.waitForFirstImage.value = true; ffiModel.waitForFirstImage.value = true;
ffiModel.isRefreshing = false; ffiModel.isRefreshing = false;
ffiModel.waitForImageDialogShow.value = true; ffiModel.waitForImageDialogShow.value = true;
@@ -3879,16 +3948,35 @@ class FFI {
} }
if (ffiModel.waitForFirstImage.value == true) { if (ffiModel.waitForFirstImage.value == true) {
ffiModel.waitForFirstImage.value = false; ffiModel.waitForFirstImage.value = false;
ffiModel.cancelPendingRestoreTimer();
ffiModel.resetRestartReconnectState();
dialogManager.dismissAll(); dialogManager.dismissAll();
await canvasModel.updateViewStyle(); try {
await canvasModel.updateScrollStyle(); await canvasModel.updateViewStyle();
await canvasModel.initializeEdgeScrollEdgeThickness(); await canvasModel.updateScrollStyle();
for (final cb in imageModel.callbacksOnFirstImage) { await canvasModel.initializeEdgeScrollEdgeThickness();
cb(id); 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. /// Login with [password], choose if the client should [remember] it.
void login(String osUsername, String osPassword, SessionID sessionId, void login(String osUsername, String osPassword, SessionID sessionId,
String password, bool remember) { String password, bool remember) {

View File

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

View File

@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/consts.dart';
@@ -27,25 +26,34 @@ class TerminalModel with ChangeNotifier {
// Buffer for output data received before terminal view has valid dimensions. // Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete. // This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[]; final _pendingOutputChunks = <String>[];
final _pendingOutputSuppressFlags = <bool>[];
int _pendingOutputSize = 0; int _pendingOutputSize = 0;
static const int _kMaxOutputBufferChars = 8 * 1024; static const int _kMaxOutputBufferChars = 8 * 1024;
// View ready state: true when terminal has valid dimensions, safe to write // View ready state: true when terminal has valid dimensions, safe to write
bool _terminalViewReady = false; bool _terminalViewReady = false;
bool _markViewReadyScheduled = false;
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows; bool _suppressTerminalOutput = false;
bool _suppressNextTerminalDataOutput = false;
void Function(int w, int h, int pw, int ph)? onResizeExternal; 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 { Future<void> _handleInput(String data) async {
// If we press the `Enter` button on Android, // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
// `data` can be '\r' or '\n' when using different keyboards. // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline. // - Peer Windows: '\r' works, '\n' is just a newline.
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command). // - Peer Linux: canonical-mode shells accept both, but raw-mode apps
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility. // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
// Desktop -> Desktop works fine. // - Peer macOS: same as Linux, raw-mode apps expect '\r'
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'. // (https://github.com/rustdesk/rustdesk/issues/14907).
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
// We deliberately do not touch multi-character payloads (e.g. pasted text)
// so embedded newlines in pasted content are preserved.
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
if (isMobileOrWebMobile && isPeerWindows && data == '\n') { if (isMobileOrWebMobile && data == '\n') {
data = '\r'; data = '\r';
} }
if (_terminalOpened) { if (_terminalOpened) {
@@ -70,7 +78,10 @@ class TerminalModel with ChangeNotifier {
terminalController = TerminalController(); terminalController = TerminalController();
// Setup terminal callbacks // Setup terminal callbacks
terminal.onOutput = _handleInput; terminal.onOutput = (data) {
if (_suppressTerminalOutput) return;
_handleInput(data);
};
terminal.onResize = (w, h, pw, ph) async { terminal.onResize = (w, h, pw, ph) async {
// Validate all dimensions before using them // Validate all dimensions before using them
@@ -84,7 +95,7 @@ class TerminalModel with ChangeNotifier {
// Mark terminal view as ready and flush any buffered output on first valid resize. // Mark terminal view as ready and flush any buffered output on first valid resize.
// Must be after onResizeExternal so the view layer has valid dimensions before flushing. // Must be after onResizeExternal so the view layer has valid dimensions before flushing.
if (!_terminalViewReady) { if (!_terminalViewReady) {
_markViewReady(); _scheduleMarkViewReady();
} }
if (_terminalOpened) { if (_terminalOpened) {
@@ -110,14 +121,16 @@ class TerminalModel with ChangeNotifier {
void onReady() { void onReady() {
parent.dialogManager.dismissAll(); parent.dialogManager.dismissAll();
// Fire and forget - don't block onReady // Fire and forget - don't block onReady. If the transport reconnects while
openTerminal().catchError((e) { // this model is still open, re-send OpenTerminal so the remote service marks
// the persistent session active again and resumes output streaming.
openTerminal(force: _terminalOpened).catchError((e) {
debugPrint('[TerminalModel] Error opening terminal: $e'); debugPrint('[TerminalModel] Error opening terminal: $e');
}); });
} }
Future<void> openTerminal() async { Future<void> openTerminal({bool force = false}) async {
if (_terminalOpened) return; if (_terminalOpened && !force) return;
// Request the remote side to open a terminal with default shell // Request the remote side to open a terminal with default shell
// The remote side will decide which shell to use based on its OS // The remote side will decide which shell to use based on its OS
@@ -237,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) { void handleTerminalResponse(Map<String, dynamic> evt) {
final String? type = evt['type']; final String? type = evt['type'];
final int evtTerminalId = getTerminalIdFromEvt(evt); final int evtTerminalId = getTerminalIdFromEvt(evt);
@@ -275,9 +315,12 @@ class TerminalModel with ChangeNotifier {
if (success) { if (success) {
_terminalOpened = true; _terminalOpened = true;
// On reconnect ("Reconnected to existing terminal"), server may replay recent output. // On reconnect, the server may replay recent output. That replay can include
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear. // terminal queries like DSR/DA; xterm answers them through onOutput as
// We intentionally accept this tradeoff for now to keep logic simple. // "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
final replayTerminalOutput = evt['replay_terminal_output'];
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
message == 'Reconnected to existing terminal with pending output';
// Fallback: if terminal view is not yet ready but already has valid // Fallback: if terminal view is not yet ready but already has valid
// dimensions (e.g. layout completed before open response arrived), // dimensions (e.g. layout completed before open response arrived),
@@ -285,7 +328,7 @@ class TerminalModel with ChangeNotifier {
if (!_terminalViewReady && if (!_terminalViewReady &&
terminal.viewWidth > 0 && terminal.viewWidth > 0 &&
terminal.viewHeight > 0) { terminal.viewHeight > 0) {
_markViewReady(); _scheduleMarkViewReady();
} }
// Process any buffered input // Process any buffered input
@@ -297,12 +340,16 @@ class TerminalModel with ChangeNotifier {
}); });
final persistentSessions = final persistentSessions =
evt['persistent_sessions'] as List<dynamic>? ?? []; (evt['persistent_sessions'] as List<dynamic>? ?? [])
.whereType<int>()
.where((id) => !parent.terminalModels.containsKey(id))
.toList();
if (kWindowId != null && persistentSessions.isNotEmpty) { if (kWindowId != null && persistentSessions.isNotEmpty) {
DesktopMultiWindow.invokeMethod( DesktopMultiWindow.invokeMethod(
kWindowId!, kWindowId!,
kWindowEventRestoreTerminalSessions, kWindowEventRestoreTerminalSessions,
jsonEncode({ jsonEncode({
'peer_id': id,
'persistent_sessions': persistentSessions, 'persistent_sessions': persistentSessions,
})); }));
} }
@@ -332,6 +379,8 @@ class TerminalModel with ChangeNotifier {
final data = evt['data']; final data = evt['data'];
if (data != null) { if (data != null) {
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
_suppressNextTerminalDataOutput = false;
try { try {
String text = ''; String text = '';
if (data is String) { if (data is String) {
@@ -351,7 +400,7 @@ class TerminalModel with ChangeNotifier {
return; return;
} }
_writeToTerminal(text); _writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
} catch (e) { } catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $e'); debugPrint('[TerminalModel] Failed to process terminal data: $e');
} }
@@ -361,7 +410,10 @@ class TerminalModel with ChangeNotifier {
/// Write text to terminal, buffering if the view is not yet ready. /// Write text to terminal, buffering if the view is not yet ready.
/// All terminal output should go through this method to avoid NaN errors /// All terminal output should go through this method to avoid NaN errors
/// from writing before the terminal view has valid layout dimensions. /// from writing before the terminal view has valid layout dimensions.
void _writeToTerminal(String text) { void _writeToTerminal(
String text, {
bool suppressTerminalOutput = false,
}) {
if (!_terminalViewReady) { if (!_terminalViewReady) {
// If a single chunk exceeds the cap, keep only its tail. // If a single chunk exceeds the cap, keep only its tail.
// Note: truncation may split a multi-byte ANSI escape sequence, // Note: truncation may split a multi-byte ANSI escape sequence,
@@ -373,34 +425,73 @@ class TerminalModel with ChangeNotifier {
_pendingOutputChunks _pendingOutputChunks
..clear() ..clear()
..add(truncated); ..add(truncated);
_pendingOutputSuppressFlags
..clear()
..add(suppressTerminalOutput);
_pendingOutputSize = truncated.length; _pendingOutputSize = truncated.length;
} else { } else {
_pendingOutputChunks.add(text); _pendingOutputChunks.add(text);
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
_pendingOutputSize += text.length; _pendingOutputSize += text.length;
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences) // Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
while (_pendingOutputSize > _kMaxOutputBufferChars && while (_pendingOutputSize > _kMaxOutputBufferChars &&
_pendingOutputChunks.length > 1) { _pendingOutputChunks.length > 1) {
final removed = _pendingOutputChunks.removeAt(0); final removed = _pendingOutputChunks.removeAt(0);
_pendingOutputSuppressFlags.removeAt(0);
_pendingOutputSize -= removed.length; _pendingOutputSize -= removed.length;
} }
} }
return; return;
} }
terminal.write(text); _writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
} }
void _flushOutputBuffer() { void _flushOutputBuffer() {
if (_pendingOutputChunks.isEmpty) return; if (_pendingOutputChunks.isEmpty) return;
debugPrint( debugPrint(
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)'); '[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
for (final chunk in _pendingOutputChunks) { for (var i = 0; i < _pendingOutputChunks.length; i++) {
terminal.write(chunk); _writeTerminalChunk(
_pendingOutputChunks[i],
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
);
} }
_pendingOutputChunks.clear(); _pendingOutputChunks.clear();
_pendingOutputSuppressFlags.clear();
_pendingOutputSize = 0; _pendingOutputSize = 0;
} }
void _writeTerminalChunk(
String text, {
required bool suppressTerminalOutput,
}) {
if (!suppressTerminalOutput) {
terminal.write(text);
return;
}
final previous = _suppressTerminalOutput;
_suppressTerminalOutput = true;
try {
terminal.write(text);
} finally {
_suppressTerminalOutput = previous;
}
}
/// Mark terminal view as ready and flush buffered output. /// Mark terminal view as ready and flush buffered output.
void _scheduleMarkViewReady() {
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
_markViewReadyScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_markViewReadyScheduled = false;
if (_disposed || _terminalViewReady) return;
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
_markViewReady();
}
});
WidgetsBinding.instance.ensureVisualUpdate();
}
void _markViewReady() { void _markViewReady() {
if (_terminalViewReady) return; if (_terminalViewReady) return;
_terminalViewReady = true; _terminalViewReady = true;
@@ -408,10 +499,12 @@ class TerminalModel with ChangeNotifier {
} }
void _handleTerminalClosed(Map<String, dynamic> evt) { 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'); _writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
_terminalOpened = false; _terminalOpened = false;
notifyListeners(); notifyListeners();
// Auto-close the tab/page
onClosed?.call();
} }
void _handleTerminalError(Map<String, dynamic> evt) { void _handleTerminalError(Map<String, dynamic> evt) {
@@ -426,7 +519,10 @@ class TerminalModel with ChangeNotifier {
// Clear buffers to free memory // Clear buffers to free memory
_inputBuffer.clear(); _inputBuffer.clear();
_pendingOutputChunks.clear(); _pendingOutputChunks.clear();
_pendingOutputSuppressFlags.clear();
_pendingOutputSize = 0; _pendingOutputSize = 0;
_markViewReadyScheduled = false;
_suppressNextTerminalDataOutput = false;
// Terminal cleanup is handled server-side when service closes // Terminal cleanup is handled server-side when service closes
super.dispose(); super.dispose();
} }

View File

@@ -1914,6 +1914,15 @@ class RustdeskImpl {
throw UnimplementedError("sessionHandleScreenshot"); 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( String? sessionGetCommonSync(
{required UuidValue sessionId, {required UuidValue sessionId,
required String key, required String key,
@@ -2034,7 +2043,14 @@ class RustdeskImpl {
} }
String mainResolveAvatarUrl({required String avatar, dynamic hint}) { String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar; return js.context.callMethod(
'getByName', ['resolve_avatar_url', avatar])?.toString() ??
avatar;
}
Future<String> mainDeployDevice(
{required String token, required String id, dynamic hint}) {
throw UnimplementedError("mainDeployDevice");
} }
void dispose() {} void dispose() {}

View File

@@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk
PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb
// The copyright displayed in application information // 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

@@ -1,2 +1,2 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cargo ndk --platform 21 --target armv7-linux-androideabi build --release --features flutter,hwcodec cargo ndk --platform 21 --target armv7-linux-androideabi build --locked --release --features flutter,hwcodec

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cargo ndk --platform 21 --target aarch64-linux-android build --release --features flutter,hwcodec cargo ndk --platform 21 --target aarch64-linux-android build --locked --release --features flutter,hwcodec

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cargo ndk --platform 21 --target x86_64-linux-android build --release --features flutter cargo ndk --platform 21 --target x86_64-linux-android build --locked --release --features flutter

View File

@@ -7,4 +7,4 @@
export CFLAGS="-DBROKEN_CLANG_ATOMICS" export CFLAGS="-DBROKEN_CLANG_ATOMICS"
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS" export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter cargo ndk --platform 21 --target i686-linux-android build --locked --release --features flutter

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 # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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 # 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.6+64 version: 1.4.9+67
environment: environment:
sdk: '^3.1.0' sdk: '^3.1.0'

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
flutter pub get flutter pub get
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ../src/flutter_ffi.rs --dart-output ./lib/generated_bridge.dart --c-output ./macos/Runner/bridge_generated.h ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ../src/flutter_ffi.rs --dart-output ./lib/generated_bridge.dart --c-output ./macos/Runner/bridge_generated.h
# call `flutter clean` if cargo build fails # call `flutter clean` if cargo build fails
# export LLVM_HOME=/Library/Developer/CommandLineTools/usr/ # export LLVM_HOME=/Library/Developer/CommandLineTools/usr/
cargo build --features flutter cargo build --locked --features flutter
flutter run $@ flutter run $@

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_test/flutter_test.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 = [ import 'cm_demo.dart' as cm_demo;
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)
];
/// flutter run -d {platform} -t test/cm_test.dart to test cm void main() {
void main(List<String> args) async { test('connection manager demo clients match the current Client API', () {
isTest = true; expect(cm_demo.testClients, hasLength(4));
WidgetsFlutterBinding.ensureInitialized(); expect(cm_demo.testClients.map((client) => client.name), [
await windowManager.ensureInitialized(); 'UserAAAAAA',
await windowManager.setSize(const Size(400, 600)); 'UserBBBBB',
await windowManager.setAlignment(Alignment.topRight); 'UserC',
await initEnv(kAppTypeMain); 'UserDDDDDDDDDDDd',
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 expect(
windowManager.setAlignment(Alignment.topRight); 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 BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "Purslane Ltd" "\0" VALUE "CompanyName", "Purslane Tech Pte. Ltd." "\0"
VALUE "FileDescription", "RustDesk Remote Desktop" "\0" VALUE "FileDescription", "RustDesk Remote Desktop" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "rustdesk" "\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 "OriginalFilename", "rustdesk.exe" "\0"
VALUE "ProductName", "RustDesk" "\0" VALUE "ProductName", "RustDesk" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"

View File

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

View File

@@ -4,23 +4,24 @@ use super::filetype::FileDescription;
use crate::{ClipboardFile, CliprdrError}; use crate::{ClipboardFile, CliprdrError};
use cs::FuseServer; use cs::FuseServer;
use fuser::MountOption; use fuser::MountOption;
use hbb_common::{config::APP_NAME, log}; use hbb_common::{config::Config, log};
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{ use std::{
path::PathBuf, io,
path::{Path, PathBuf},
sync::{mpsc::Sender, Arc}, sync::{mpsc::Sender, Arc},
time::Duration, time::Duration,
}; };
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref FUSE_MOUNT_POINT_CLIENT: Arc<String> = { 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. // No need to run `canonicalize()` here.
Arc::new(mnt_path) Arc::new(mnt_path)
}; };
static ref FUSE_MOUNT_POINT_SERVER: Arc<String> = { 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. // No need to run `canonicalize()` here.
Arc::new(mnt_path) Arc::new(mnt_path)
}; };
@@ -31,6 +32,21 @@ lazy_static::lazy_static! {
static FUSE_TIMEOUT: Duration = Duration::from_secs(3); 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> { pub fn get_exclude_paths(is_client: bool) -> Arc<String> {
if is_client { if is_client {
FUSE_MOUNT_POINT_CLIENT.clone() FUSE_MOUNT_POINT_CLIENT.clone()
@@ -53,8 +69,27 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
} else { } else {
FUSE_CONTEXT_SERVER.lock() FUSE_CONTEXT_SERVER.lock()
}; };
if fuse_context_lock.is_some() { if let Some(ctx) = fuse_context_lock.as_ref() {
return Ok(()); 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 { let mount_point = if is_client {
FUSE_MOUNT_POINT_CLIENT.clone() 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); 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, tx) = FuseServer::new(FUSE_TIMEOUT);
let server = Arc::new(Mutex::new(server)); let server = Arc::new(Mutex::new(server));
prepare_fuse_mount_point(&mount_point); prepare_fuse_mount_point(&mount_point)?;
let mnt_opts = [ let mnt_opts = [
MountOption::FSName("rustdesk-cliprdr-fs".to_string()), MountOption::FSName("rustdesk-cliprdr-fs".to_string()),
MountOption::NoAtime, MountOption::NoAtime,
@@ -159,35 +216,246 @@ struct FuseContext {
} }
// this function must be called after the main IPC is up // 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::{ use std::{
fs::{self, Permissions}, fs::{self, Permissions},
os::unix::prelude::PermissionsExt, os::unix::prelude::PermissionsExt,
}; };
fs::create_dir(mount_point).ok(); if let Some(parent) = mount_point.parent() {
fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok(); 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") reject_symlink_path(mount_point)?;
.arg(mount_point)
.status() let recovered_stale_mount = if let Err(e) = fs::create_dir_all(mount_point) {
{ log::warn!(
log::warn!("umount {:?} may fail: {:?}", mount_point, e); "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) { fn inspect_mount_point_state_with<T>(
if is_client { mount_point: &Path,
let _ = FUSE_CONTEXT_CLIENT.lock().take(); metadata_result: io::Result<T>,
} else { mountinfo_result: io::Result<String>,
let _ = FUSE_CONTEXT_SERVER.lock().take(); ) -> 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 { impl Drop for FuseContext {
fn drop(&mut self) { fn drop(&mut self) {
self.session.lock().take().map(|s| s.join()); 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()) .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 handle
.seek(std::io::SeekFrom::Start(offset)) .seek(std::io::SeekFrom::Start(offset))
.map_err(|e| CliprdrError::FileError { .and_then(|_| handle.read_exact(buf))
path: self.path.to_string_lossy().to_string(), } else {
err: e, 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); let new_offset = offset + (buf.len() as u64);
self.offset.store(new_offset, Ordering::Relaxed); self.offset.store(new_offset, Ordering::Relaxed);
@@ -217,6 +213,15 @@ impl LocalFile {
Ok(()) 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> { 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)] #[cfg(test)]
mod file_list_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}; use hbb_common::bytes::{BufMut, BytesMut};
@@ -384,4 +392,30 @@ mod file_list_test {
as_bin_parse_test("/test")?; as_bin_parse_test("/test")?;
Ok(()) 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, log,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{path::PathBuf, sync::Arc, usize}; use std::{path::PathBuf, sync::Arc, time::SystemTime, usize};
lazy_static::lazy_static! { lazy_static::lazy_static! {
// local files are cached, this value should not be changed when copying files // 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)] #[derive(Default)]
struct ClipFiles { struct ClipFiles {
files: Vec<String>, files: Vec<String>,
// Fingerprint of `files` (same len/order); detects in-place edits on re-copy.
sigs: Vec<FileSig>,
file_list: Vec<LocalFile>, file_list: Vec<LocalFile>,
first_file_index: usize, first_file_index: usize,
files_pdu: Vec<u8>, files_pdu: Vec<u8>,
@@ -41,12 +67,17 @@ struct ClipFiles {
impl ClipFiles { impl ClipFiles {
fn clear(&mut self) { fn clear(&mut self) {
self.files.clear(); self.files.clear();
self.sigs.clear();
self.file_list.clear(); self.file_list.clear();
self.first_file_index = usize::MAX; self.first_file_index = usize::MAX;
self.files_pdu.clear(); 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 let clipboard_paths = clipboard_files
.iter() .iter()
.map(|s| PathBuf::from(s)) .map(|s| PathBuf::from(s))
@@ -58,6 +89,7 @@ impl ClipFiles {
.position(|f| !f.path.is_dir()) .position(|f| !f.path.is_dir())
.unwrap_or(usize::MAX); .unwrap_or(usize::MAX);
self.files = clipboard_files.to_vec(); self.files = clipboard_files.to_vec();
self.sigs = sigs;
Ok(()) Ok(())
} }
@@ -258,14 +290,128 @@ pub fn read_file_contents(
} }
pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> { 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(); 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(()); return Ok(());
} }
files_lock.sync_files(files)?; files_lock.sync_files(files, current)?;
Ok(files_lock.build_file_list_pdu()) Ok(files_lock.build_file_list_pdu())
} }
pub fn get_file_list_pdu() -> Vec<u8> { pub fn get_file_list_pdu() -> Vec<u8> {
CLIP_FILES.lock().files_pdu.clone() 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

@@ -39,6 +39,55 @@
#define CLIPRDR_SVC_CHANNEL_NAME "cliprdr" #define CLIPRDR_SVC_CHANNEL_NAME "cliprdr"
/* 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)
{
SIZE_T header_size = offsetof(FILEGROUPDESCRIPTORW, fgd);
SIZE_T descriptors_size;
if (count == 0 || count > WF_CLIPRDR_MAX_STREAMS)
return FALSE;
if (size < header_size)
return FALSE;
if ((SIZE_T)count > (((SIZE_T)-1) - header_size) / sizeof(FILEDESCRIPTORW))
return FALSE;
descriptors_size = header_size + (SIZE_T)count * sizeof(FILEDESCRIPTORW);
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 * Clipboard Formats
*/ */
@@ -183,6 +232,7 @@ struct _CliprdrStream
FILEDESCRIPTORW m_Dsc; FILEDESCRIPTORW m_Dsc;
void *m_pData; void *m_pData;
UINT32 m_connID; UINT32 m_connID;
UINT32 m_streamId; // unique CLIPRDR streamId; avoids leaking a heap pointer
}; };
typedef struct _CliprdrStream CliprdrStream; typedef struct _CliprdrStream CliprdrStream;
@@ -224,6 +274,7 @@ struct wf_clipboard
HWND hwnd; HWND hwnd;
HANDLE hmem; HANDLE hmem;
SIZE_T hmem_data_len;
HANDLE thread; HANDLE thread;
HANDLE formatDataRespEvent; HANDLE formatDataRespEvent;
BOOL formatDataRespReceived; BOOL formatDataRespReceived;
@@ -235,6 +286,9 @@ struct wf_clipboard
char *req_fdata; char *req_fdata;
HANDLE req_fevent; HANDLE req_fevent;
BOOL req_f_received; 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 nFiles;
size_t file_array_size; size_t file_array_size;
@@ -265,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_data_request(UINT32 connID, wfClipboard *clipboard, UINT32 format);
static UINT cliprdr_send_lock(wfClipboard *clipboard); static UINT cliprdr_send_lock(wfClipboard *clipboard);
static UINT cliprdr_send_unlock(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, ULONG index, UINT32 flag, DWORD positionhigh,
DWORD positionlow, ULONG request); DWORD positionlow, ULONG request);
@@ -348,7 +402,7 @@ static ULONG STDMETHODCALLTYPE CliprdrStream_Release(IStream *This)
static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULONG cb, static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULONG cb,
ULONG *pcbRead) ULONG *pcbRead)
{ {
int ret; UINT ret;
CliprdrStream *instance = (CliprdrStream *)This; CliprdrStream *instance = (CliprdrStream *)This;
wfClipboard *clipboard; wfClipboard *clipboard;
@@ -361,12 +415,23 @@ static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULO
if (instance->m_lOffset.QuadPart >= instance->m_lSize.QuadPart) if (instance->m_lOffset.QuadPart >= instance->m_lSize.QuadPart)
return S_FALSE; 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, FILECONTENTS_RANGE, instance->m_lOffset.HighPart,
instance->m_lOffset.LowPart, cb); instance->m_lOffset.LowPart, cb);
if (ret < 0) if (ret != CHANNEL_RC_OK)
{
free(clipboard->req_fdata);
clipboard->req_fdata = NULL;
return E_FAIL; return E_FAIL;
}
if (clipboard->req_fsize > cb)
{
free(clipboard->req_fdata);
clipboard->req_fdata = NULL;
return STG_E_READFAULT;
}
if (clipboard->req_fdata) if (clipboard->req_fdata)
{ {
@@ -578,6 +643,7 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData,
instance->m_pData = pData; instance->m_pData = pData;
instance->m_lOffset.QuadPart = 0; instance->m_lOffset.QuadPart = 0;
instance->m_connID = connID; instance->m_connID = connID;
instance->m_streamId = (UINT32)InterlockedIncrement(&clipboard->req_f_stream_id_seq);
if (instance->m_Dsc.dwFlags & FD_ATTRIBUTES) if (instance->m_Dsc.dwFlags & FD_ATTRIBUTES)
{ {
@@ -588,16 +654,28 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData,
if (((instance->m_Dsc.dwFlags & FD_FILESIZE) == 0) && !isDir) if (((instance->m_Dsc.dwFlags & FD_FILESIZE) == 0) && !isDir)
{ {
/* get content size of this stream */ /* 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, instance->m_lIndex, FILECONTENTS_SIZE, 0, 0,
8) == CHANNEL_RC_OK) 8) == CHANNEL_RC_OK)
{ {
success = TRUE; 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); free(clipboard->req_fdata);
clipboard->req_fdata = NULL; clipboard->req_fdata = NULL;
} }
@@ -629,6 +707,50 @@ void CliprdrStream_Delete(CliprdrStream *instance)
} }
} }
static void wf_cliprdr_release_streams(IStream **streams, ULONG count)
{
ULONG i;
if (!streams)
return;
for (i = 0; i < count; i++)
{
if (streams[i])
CliprdrStream_Release(streams[i]);
}
free(streams);
}
static void wf_cliprdr_reset_streams(CliprdrDataObject *instance)
{
if (!instance)
return;
wf_cliprdr_release_streams(instance->m_pStream, instance->m_nStreams);
instance->m_pStream = NULL;
instance->m_nStreams = 0;
}
/* Only call after clipboard->hmem has been locked by GlobalLock. */
static HRESULT wf_cliprdr_fail_locked_file_descriptor_data(wfClipboard *clipboard,
STGMEDIUM *medium,
CliprdrDataObject *instance,
IStream **streams,
ULONG stream_count,
HRESULT error)
{
GlobalUnlock(clipboard->hmem);
GlobalFree(clipboard->hmem);
clipboard->hmem = NULL;
clipboard->hmem_data_len = 0;
medium->hGlobal = NULL;
wf_cliprdr_release_streams(streams, stream_count);
wf_cliprdr_reset_streams(instance);
return error;
}
/** /**
* IDataObject * IDataObject
*/ */
@@ -747,6 +869,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
{ {
// FILEGROUPDESCRIPTOR *dsc; // FILEGROUPDESCRIPTOR *dsc;
FILEGROUPDESCRIPTORW *dsc; FILEGROUPDESCRIPTORW *dsc;
IStream **streams = NULL;
UINT stream_count = 0;
SIZE_T hmem_size;
// DWORD remote_format_id = get_remote_format_id(clipboard, instance->m_pFormatEtc[idx].cfFormat); // DWORD remote_format_id = get_remote_format_id(clipboard, instance->m_pFormatEtc[idx].cfFormat);
// FIXME: origin code may be failed here??? // FIXME: origin code may be failed here???
if (cliprdr_send_data_request(instance->m_connID, clipboard, instance->m_pFormatEtc[idx].cfFormat) != 0) if (cliprdr_send_data_request(instance->m_connID, clipboard, instance->m_pFormatEtc[idx].cfFormat) != 0)
@@ -764,40 +889,48 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
* is the number of FILEDESCRIPTOR's */ * is the number of FILEDESCRIPTOR's */
// dsc = (FILEGROUPDESCRIPTOR *)GlobalLock(clipboard->hmem); // dsc = (FILEGROUPDESCRIPTOR *)GlobalLock(clipboard->hmem);
dsc = (FILEGROUPDESCRIPTORW *)GlobalLock(clipboard->hmem); dsc = (FILEGROUPDESCRIPTORW *)GlobalLock(clipboard->hmem);
instance->m_nStreams = dsc->cItems; if (!dsc)
GlobalUnlock(clipboard->hmem);
if (instance->m_nStreams > 0)
{ {
if (!instance->m_pStream)
{
instance->m_pStream = (LPSTREAM *)calloc(instance->m_nStreams, sizeof(LPSTREAM));
if (instance->m_pStream)
{
for (i = 0; i < instance->m_nStreams; i++)
{
instance->m_pStream[i] =
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
if (!instance->m_pStream[i])
return E_OUTOFMEMORY;
}
}
}
}
if (!instance->m_pStream)
{
if (clipboard->hmem)
{
GlobalFree(clipboard->hmem);
clipboard->hmem = NULL;
}
pMedium->hGlobal = NULL; pMedium->hGlobal = NULL;
return E_OUTOFMEMORY; GlobalFree(clipboard->hmem);
clipboard->hmem = NULL;
clipboard->hmem_data_len = 0;
wf_cliprdr_reset_streams(instance);
return E_UNEXPECTED;
} }
hmem_size = clipboard->hmem_data_len;
/* cItems is remote-controlled; verify the fixed header exists before reading it. */
if (hmem_size < offsetof(FILEGROUPDESCRIPTORW, fgd))
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
stream_count = dsc->cItems;
if (!wf_cliprdr_file_group_descriptor_size_valid(hmem_size, stream_count))
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
streams = (IStream **)calloc(stream_count, sizeof(IStream *));
if (!streams)
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, NULL, 0, E_OUTOFMEMORY);
for (i = 0; i < stream_count; i++)
{
streams[i] =
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
if (!streams[i])
{
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, streams, i, E_OUTOFMEMORY);
}
}
GlobalUnlock(clipboard->hmem);
wf_cliprdr_reset_streams(instance);
instance->m_pStream = streams;
instance->m_nStreams = stream_count;
return S_OK;
} }
else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS)) else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS))
{ {
@@ -1328,25 +1461,35 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format)
return 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) if (!clipboard)
return; return FALSE;
if (clipboard->map_size >= clipboard->map_capacity) if (!clipboard->format_mappings)
{ return FALSE;
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 (!new_map) if (capacity <= clipboard->map_capacity)
return; return TRUE;
clipboard->format_mappings = new_map; if (capacity > WF_CLIPRDR_MAX_FORMATS ||
clipboard->map_capacity = new_size; 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) static BOOL clear_format_map(wfClipboard *clipboard)
@@ -1373,6 +1516,13 @@ static BOOL clear_format_map(wfClipboard *clipboard)
return TRUE; 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) static UINT cliprdr_send_tempdir(wfClipboard *clipboard)
{ {
CLIPRDR_TEMP_DIRECTORY tempDirectory; CLIPRDR_TEMP_DIRECTORY tempDirectory;
@@ -1651,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); 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, UINT32 flag, DWORD positionhigh, DWORD positionlow,
ULONG nreq) ULONG nreq)
{ {
UINT rc; UINT rc;
CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest; CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest = { 0 };
if (!clipboard || !clipboard->context || !clipboard->context->ClientFileContentsRequest) if (!clipboard || !clipboard->context || !clipboard->context->ClientFileContentsRequest)
return ERROR_INTERNAL_ERROR; return ERROR_INTERNAL_ERROR;
@@ -1667,12 +1817,11 @@ UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, co
return rc; return rc;
} }
clipboard->req_f_received = FALSE; clipboard->req_f_received = FALSE;
clipboard->req_f_conn_id_expected = connID;
clipboard->req_f_stream_id_expected = streamId;
fileContentsRequest.connID = connID; fileContentsRequest.connID = connID;
// streamId is `IStream*` pointer, though it is not very good on a 64-bit system. fileContentsRequest.streamId = streamId;
// 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.listIndex = index; fileContentsRequest.listIndex = index;
fileContentsRequest.dwFlags = flag; fileContentsRequest.dwFlags = flag;
fileContentsRequest.nPositionLow = positionlow; fileContentsRequest.nPositionLow = positionlow;
@@ -2161,16 +2310,16 @@ static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_fi
return FALSE; return FALSE;
/* add to name array */ /* add to name array */
clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc((size_t)MAX_PATH * sizeof(WCHAR));
if (!clipboard->file_names[clipboard->nFiles])
return FALSE;
// `MAX_PATH` is long enough for the file name. // `MAX_PATH` is long enough for the file name.
// So we just return FALSE if the file name is too long, which is not a normal case. // So we just return FALSE if the file name is too long, which is not a normal case.
if ((wcslen(full_file_name) + 1) > MAX_PATH) if ((wcslen(full_file_name) + 1) > MAX_PATH)
return FALSE; return FALSE;
clipboard->file_names[clipboard->nFiles] = (LPWSTR)calloc(MAX_PATH, sizeof(WCHAR));
if (!clipboard->file_names[clipboard->nFiles])
return FALSE;
wcsncpy_s(clipboard->file_names[clipboard->nFiles], MAX_PATH, full_file_name, wcslen(full_file_name) + 1); wcsncpy_s(clipboard->file_names[clipboard->nFiles], MAX_PATH, full_file_name, wcslen(full_file_name) + 1);
/* add to descriptor array */ /* add to descriptor array */
clipboard->fileDescriptor[clipboard->nFiles] = clipboard->fileDescriptor[clipboard->nFiles] =
@@ -2365,6 +2514,16 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
if (!clear_format_map(clipboard)) if (!clear_format_map(clipboard))
return ERROR_INTERNAL_ERROR; 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; clipboard->copied = TRUE;
@@ -2372,19 +2531,58 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
{ {
format = &(formatList->formats[i]); format = &(formatList->formats[i]);
mapping = &(clipboard->format_mappings[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; mapping->remote_format_id = format->formatId;
if (format->formatName) if (format->formatName)
{ {
int size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, size_t name_len;
strlen(format->formatName), NULL, 0); int size;
mapping->name = calloc(size + 1, sizeof(WCHAR));
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), return wf_cliprdr_server_format_list_fail(clipboard);
mapping->name, size); }
mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name);
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 else
@@ -2394,7 +2592,6 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
} }
clipboard->map_size++; clipboard->map_size++;
map_ensure_capacity(clipboard);
} }
if (file_transferring(clipboard)) if (file_transferring(clipboard))
@@ -2778,6 +2975,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
break; break;
} }
clipboard->hmem = NULL; clipboard->hmem = NULL;
clipboard->hmem_data_len = 0;
if (formatDataResponse->msgFlags != CB_RESPONSE_OK) if (formatDataResponse->msgFlags != CB_RESPONSE_OK)
{ {
@@ -2811,6 +3009,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
break; break;
} }
clipboard->hmem_data_len = formatDataResponse->dataLen;
clipboard->hmem = hMem; clipboard->hmem = hMem;
rc = CHANNEL_RC_OK; rc = CHANNEL_RC_OK;
} while (0); } while (0);
@@ -2843,6 +3042,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
BOOL bIsStreamFile = TRUE; BOOL bIsStreamFile = TRUE;
static LPSTREAM pStreamStc = NULL; static LPSTREAM pStreamStc = NULL;
static UINT32 uStreamIdStc = 0; static UINT32 uStreamIdStc = 0;
static UINT32 uConnIdStc = 0;
wfClipboard *clipboard; wfClipboard *clipboard;
UINT rc = ERROR_INTERNAL_ERROR; UINT rc = ERROR_INTERNAL_ERROR;
UINT sRc; UINT sRc;
@@ -2916,7 +3116,8 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
vFormatEtc.lindex = fileContentsRequest->listIndex; vFormatEtc.lindex = fileContentsRequest->listIndex;
vFormatEtc.ptd = NULL; vFormatEtc.ptd = NULL;
if ((uStreamIdStc != fileContentsRequest->streamId) || !pStreamStc) if ((uStreamIdStc != fileContentsRequest->streamId) ||
(uConnIdStc != fileContentsRequest->connID) || !pStreamStc)
{ {
LPENUMFORMATETC pEnumFormatEtc; LPENUMFORMATETC pEnumFormatEtc;
ULONG CeltFetched; ULONG CeltFetched;
@@ -2947,6 +3148,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
{ {
pStreamStc = vStgMedium.pstm; pStreamStc = vStgMedium.pstm;
uStreamIdStc = fileContentsRequest->streamId; uStreamIdStc = fileContentsRequest->streamId;
uConnIdStc = fileContentsRequest->connID;
bIsStreamFile = TRUE; bIsStreamFile = TRUE;
} }
@@ -3110,6 +3312,9 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context,
rc = ERROR_INTERNAL_ERROR; rc = ERROR_INTERNAL_ERROR;
break; 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_fsize = 0;
clipboard->req_fdata = NULL; clipboard->req_fdata = NULL;
@@ -3120,6 +3325,13 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context,
} }
clipboard->req_fsize = fileContentsResponse->cbRequested; 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); clipboard->req_fdata = (char *)malloc(fileContentsResponse->cbRequested);
if (!clipboard->req_fdata) if (!clipboard->req_fdata)
{ {

View File

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

View File

@@ -223,7 +223,7 @@ impl KeyboardControllable for Enigo {
// Windows uses uft-16 encoding. We need to check // Windows uses uft-16 encoding. We need to check
// for variable length characters. As such some // for variable length characters. As such some
// characters can be 32 bit long and those are // 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 // each 16 bit wide that needs to be send after
// another to the SendInput function without // another to the SendInput function without
// being interrupted by "keyup" // being interrupted by "keyup"

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf {
format!("{}-{}", target_arch, target_os) format!("{}-{}", target_arch, target_os)
} }
} else if target_os == "windows" { } else if target_os == "windows" {
"x64-windows-static".to_owned() format!("{}-windows-static", target_arch)
} else { } else {
format!("{}-{}", target_arch, target_os) format!("{}-{}", target_arch, target_os)
}; };

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 { fn get_super_block_size(width: u32, height: u32, threads: u32) -> aom_superblock_size_t {
use aom_superblock_size::*; use aom_superblock_size::*;
let resolution = width * height; let resolution = width * height;
@@ -160,8 +164,7 @@ mod webrtc {
} else { } else {
AV1E_SET_TILE_COLUMNS AV1E_SET_TILE_COLUMNS
}; };
// Failed on android call_ctl!(ctx, tile_set, tile_log2(cfg.g_threads));
call_ctl!(ctx, tile_set, (cfg.g_threads as f64 * 1.0f64).log2().ceil());
call_ctl!(ctx, AV1E_SET_ROW_MT, 1); call_ctl!(ctx, AV1E_SET_ROW_MT, 1);
call_ctl!(ctx, AV1E_SET_ENABLE_OBMC, 0); call_ctl!(ctx, AV1E_SET_ENABLE_OBMC, 0);
call_ctl!(ctx, AV1E_SET_NOISE_SENSITIVITY, 0); call_ctl!(ctx, AV1E_SET_NOISE_SENSITIVITY, 0);
@@ -197,6 +200,23 @@ mod webrtc {
Ok(()) 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 { impl EncoderApi for AomEncoder {

View File

@@ -52,6 +52,33 @@ lazy_static::lazy_static! {
static ref MAG_BUFFER: Mutex<(bool, Vec<u8>)> = Default::default(); 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 REFWICPixelFormatGUID = *const GUID;
pub type WICPixelFormatGUID = GUID; pub type WICPixelFormatGUID = GUID;
@@ -247,6 +274,8 @@ pub struct CapturerMag {
rect: RECT, rect: RECT,
width: usize, width: usize,
height: usize, height: usize,
excluded_window_target: Option<(String, String)>,
excluded_windows: Vec<HWND>,
} }
impl Drop for CapturerMag { impl Drop for CapturerMag {
@@ -261,6 +290,10 @@ impl CapturerMag {
MagInterface::new().is_ok() 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> { pub(crate) fn new(origin: (i32, i32), width: usize, height: usize) -> Result<Self> {
unsafe { unsafe {
let x = GetSystemMetrics(SM_XVIRTUALSCREEN); let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
@@ -305,6 +338,8 @@ impl CapturerMag {
}, },
width, width,
height, height,
excluded_window_target: None,
excluded_windows: Vec::new(),
}; };
unsafe { unsafe {
@@ -436,19 +471,41 @@ impl CapturerMag {
} }
pub(crate) fn exclude(&mut self, cls: &str, name: &str) -> Result<bool> { 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 { 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) = if let Some(set_window_filter_list_func) =
self.mag_interface.set_window_filter_list_func self.mag_interface.set_window_filter_list_func
{ {
@@ -456,16 +513,15 @@ impl CapturerMag {
== set_window_filter_list_func( == set_window_filter_list_func(
self.magnifier_window, self.magnifier_window,
MW_FILTERMODE_EXCLUDE, MW_FILTERMODE_EXCLUDE,
1, count,
&mut hwnd, hwnds.as_mut_ptr(),
) )
{ {
return Err(Error::new( return Err(Error::new(
ErrorKind::Other, ErrorKind::Other,
format!( format!(
"Failed MagSetWindowFilterList for cls {} name {}, error {}", "Failed MagSetWindowFilterList for {} windows, error {}",
cls, count,
name,
Error::last_os_error() Error::last_os_error()
), ),
)); ));
@@ -496,6 +552,7 @@ impl CapturerMag {
} }
pub(crate) fn frame(&mut self, data: &mut Vec<u8>) -> Result<()> { pub(crate) fn frame(&mut self, data: &mut Vec<u8>) -> Result<()> {
self.refresh_excluded_windows()?;
Self::clear_data(); Self::clear_data();
unsafe { unsafe {

View File

@@ -276,12 +276,21 @@ impl PipeWireRecorder {
// see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982 // see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982
src.set_property("always-copy", &true)?; src.set_property("always-copy", &true)?;
// COSMIC/Wayland fix: insert videoconvert between pipewiresrc and appsink.
// xdg-desktop-portal-cosmic's modifier negotiation fails when the downstream
// format set is too narrow (appsink only accepts BGRx/RGBx), producing
// "no more output formats" / not-negotiated (-4). videoconvert accepts any
// system-memory video/x-raw format, widening negotiation so the portal can
// settle on a format it can deliver via its SHM path.
let convert = gst::ElementFactory::make("videoconvert", None)?;
let sink = gst::ElementFactory::make("appsink", None)?; let sink = gst::ElementFactory::make("appsink", None)?;
sink.set_property("drop", &true)?; sink.set_property("drop", &true)?;
sink.set_property("max-buffers", &1u32)?; sink.set_property("max-buffers", &1u32)?;
pipeline.add_many(&[&src, &sink])?; pipeline.add_many(&[&src, &convert, &sink])?;
src.link(&sink)?; src.link(&convert)?;
convert.link(&sink)?;
let appsink = sink let appsink = sink
.dynamic_cast::<AppSink>() .dynamic_cast::<AppSink>()

View File

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

View File

@@ -1,3 +1,3 @@
#! /usr/bin/env bash #! /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 cargo run # to bump version in cargo lock

View File

@@ -31,17 +31,17 @@ LExit:
return WcaFinalize(er); return WcaFinalize(er);
} }
// Helper function to safely delete a file or directory using handle-based deletion. // Helper function to safely delete a file using handle-based deletion.
// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions. // Directories are refused after opening the handle.
BOOL SafeDeleteItem(LPCWSTR fullPath) BOOL SafeDeleteItem(LPCWSTR fullPath)
{ {
// Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT // Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT
// to prevent following symlinks. // to prevent following symlinks.
// Use shared access to allow deletion even when other processes have the file open. // Use shared access to allow deletion even when other processes have the file open.
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT; DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
HANDLE hFile = CreateFileW( HANDLE hFile = CreateFileW(
fullPath, fullPath,
DELETE, DELETE | FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
NULL, NULL,
OPEN_EXISTING, OPEN_EXISTING,
@@ -55,6 +55,21 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
return FALSE; return FALSE;
} }
BY_HANDLE_FILE_INFORMATION fileInfo;
if (FALSE == GetFileInformationByHandle(hFile, &fileInfo))
{
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to inspect '%ls'. Error: %lu", fullPath, GetLastError());
CloseHandle(hFile);
return FALSE;
}
if (fileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
{
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Refusing to delete directory '%ls'.", fullPath);
CloseHandle(hFile);
return FALSE;
}
// Use SetFileInformationByHandle to mark for deletion. // Use SetFileInformationByHandle to mark for deletion.
// The file will be deleted when the handle is closed. // The file will be deleted when the handle is closed.
FILE_DISPOSITION_INFO dispInfo; FILE_DISPOSITION_INFO dispInfo;
@@ -77,98 +92,74 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
return result; return result;
} }
// Helper function to recursively delete a directory's contents with detailed logging. BOOL PathEndsWithSlash(LPCWSTR path)
void RecursiveDelete(LPCWSTR path)
{ {
// Ensure the path is not empty or null. size_t length = 0;
if (path == NULL || path[0] == L'\0') HRESULT hr = StringCchLengthW(path, MAX_PATH, &length);
if (FAILED(hr) || length == 0)
{
return FALSE;
}
WCHAR last = path[length - 1];
return last == L'\\' || last == L'/';
}
void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes)
{
if (!(attributes & FILE_ATTRIBUTE_READONLY))
{ {
return; return;
} }
// Extra safety: never operate directly on a root path. DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY;
if (PathIsRootW(path)) if (writableAttributes == 0)
{ {
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path); writableAttributes = FILE_ATTRIBUTE_NORMAL;
}
if (SetFileAttributesW(fullPath, writableAttributes))
{
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath);
return; return;
} }
// MAX_PATH is enough here since the installer should not be using longer paths. WcaLog(LOGMSG_STANDARD, "Runtime cleanup failed to clear read-only attribute for '%ls'. Error: %lu", fullPath, GetLastError());
// No need to handle extended-length paths (\\?\) in this context. }
WCHAR searchPath[MAX_PATH];
HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path); BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName)
if (FAILED(hr)) { {
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path); WCHAR fullPath[MAX_PATH];
return; LPCWSTR separator = PathEndsWithSlash(installFolder) ? L"" : L"\\";
HRESULT hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s%s%s", installFolder, separator, fileName);
if (FAILED(hr))
{
WcaLog(LOGMSG_STANDARD, "Runtime cleanup path is too long for '%ls'.", fileName);
return FALSE;
} }
WIN32_FIND_DATAW findData; DWORD attributes = GetFileAttributesW(fullPath);
HANDLE hFind = FindFirstFileW(searchPath, &findData); if (attributes == INVALID_FILE_ATTRIBUTES)
if (hFind == INVALID_HANDLE_VALUE)
{ {
// This can happen if the directory is empty or doesn't exist, which is not an error in our case. DWORD error = GetLastError();
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError()); if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND)
return; {
return TRUE;
}
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error);
return FALSE;
} }
do if (attributes & FILE_ATTRIBUTE_DIRECTORY)
{ {
// Skip '.' and '..' directories. WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath);
if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0) return FALSE;
{
continue;
}
// MAX_PATH is enough here since the installer should not be using longer paths.
// No need to handle extended-length paths (\\?\) in this context.
WCHAR fullPath[MAX_PATH];
hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName);
if (FAILED(hr)) {
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path);
continue;
}
// Before acting, ensure the read-only attribute is not set.
if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY)
{
if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY))
{
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError());
}
}
if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
{
// Check for reparse points (symlinks/junctions) to prevent directory traversal attacks.
// Do not follow reparse points, only remove the link itself.
if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)
{
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath);
SafeDeleteItem(fullPath);
}
else
{
// Recursively delete directory contents first
RecursiveDelete(fullPath);
// Then delete the directory itself
SafeDeleteItem(fullPath);
}
}
else
{
// Delete file using safe handle-based deletion
SafeDeleteItem(fullPath);
}
} while (FindNextFileW(hFind, &findData) != 0);
DWORD lastError = GetLastError();
if (lastError != ERROR_NO_MORE_FILES)
{
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError);
} }
FindClose(hFind); ClearReadOnlyAttribute(fullPath, attributes);
WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath);
return SafeDeleteItem(fullPath);
} }
// See `Package.wxs` for the sequence of this custom action. // See `Package.wxs` for the sequence of this custom action.
@@ -178,13 +169,13 @@ void RecursiveDelete(LPCWSTR path)
// 2. RemoveExistingProducts // 2. RemoveExistingProducts
// ├─ TerminateProcesses // ├─ TerminateProcesses
// ├─ TryStopDeleteService // ├─ TryStopDeleteService
// ├─ RemoveInstallFolder - <-- Here // ├─ RemoveRuntimeGeneratedFiles - <-- Here
// └─ RemoveFiles // └─ RemoveFiles
// 3. InstallValidate // 3. InstallValidate
// 4. InstallFiles // 4. InstallFiles
// 5. InstallExecute // 5. InstallExecute
// 6. InstallFinalize // 6. InstallFinalize
UINT __stdcall RemoveInstallFolder( UINT __stdcall RemoveRuntimeGeneratedFiles(
__in MSIHANDLE hInstall) __in MSIHANDLE hInstall)
{ {
HRESULT hr = S_OK; HRESULT hr = S_OK;
@@ -194,7 +185,7 @@ UINT __stdcall RemoveInstallFolder(
LPWSTR pwz = NULL; LPWSTR pwz = NULL;
LPWSTR pwzData = NULL; LPWSTR pwzData = NULL;
hr = WcaInitialize(hInstall, "RemoveInstallFolder"); hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles");
ExitOnFailure(hr, "Failed to initialize"); ExitOnFailure(hr, "Failed to initialize");
hr = WcaGetProperty(L"CustomActionData", &pwzData); hr = WcaGetProperty(L"CustomActionData", &pwzData);
@@ -202,24 +193,20 @@ UINT __stdcall RemoveInstallFolder(
pwz = pwzData; pwz = pwzData;
hr = WcaReadStringFromCaData(&pwz, &installFolder); hr = WcaReadStringFromCaData(&pwz, &installFolder);
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz); ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz);
if (installFolder == NULL || installFolder[0] == L'\0') { if (installFolder == NULL || installFolder[0] == L'\0') {
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete."); WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup.");
goto LExit; goto LExit;
} }
if (PathIsRootW(installFolder)) { if (PathIsRootW(installFolder)) {
WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder); WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder);
goto LExit; goto LExit;
} }
WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder); WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder);
DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe");
RecursiveDelete(installFolder);
// The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories.
// We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer.
LExit: LExit:
ReleaseStr(pwzData); ReleaseStr(pwzData);

View File

@@ -2,7 +2,7 @@ LIBRARY "CustomActions"
EXPORTS EXPORTS
CustomActionHello CustomActionHello
RemoveInstallFolder RemoveRuntimeGeneratedFiles
TerminateProcesses TerminateProcesses
AddFirewallRules AddFirewallRules
SetPropertyIsServiceRunning SetPropertyIsServiceRunning

View File

@@ -7,6 +7,10 @@
<Configuration>Release</Configuration> <Configuration>Release</Configuration>
<Platform>x64</Platform> <Platform>x64</Platform>
</ProjectConfiguration> </ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup> </ItemGroup>
<PropertyGroup Label="Globals"> <PropertyGroup Label="Globals">
<Keyword>Win32Proj</Keyword> <Keyword>Win32Proj</Keyword>
@@ -22,6 +26,12 @@
<WholeProgramOptimization>true</WholeProgramOptimization> <WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet> <CharacterSet>Unicode</CharacterSet>
</PropertyGroup> </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" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings"> <ImportGroup Label="ExtensionSettings">
</ImportGroup> </ImportGroup>
@@ -30,6 +40,9 @@
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> <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" /> <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup> </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" /> <PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile> <ClCompile>
@@ -53,6 +66,28 @@
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile> <ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
</Link> </Link>
</ItemDefinitionGroup> </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> <ItemGroup>
<ClInclude Include="Common.h" /> <ClInclude Include="Common.h" />
<ClInclude Include="framework.h" /> <ClInclude Include="framework.h" />
@@ -65,6 +100,7 @@
<ClCompile Include="FirewallRules.cpp" /> <ClCompile Include="FirewallRules.cpp" />
<ClCompile Include="pch.cpp"> <ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader> <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">Create</PrecompiledHeader>
</ClCompile> </ClCompile>
<ClCompile Include="ReadConfig.cpp" /> <ClCompile Include="ReadConfig.cpp" />
<ClCompile Include="RemotePrinter.cpp" /> <ClCompile Include="RemotePrinter.cpp" />

View File

@@ -16,8 +16,15 @@
<!-- If a command line value was stored, restore it after the registry search has been performed --> <!-- If a command line value was stored, restore it after the registry search has been performed -->
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" /> <SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
<!-- If a command line value or registry value was set, update the main properties with the value --> <!-- Normalize INSTALLFOLDER from the command line or registry before assigning INSTALLFOLDER_INNER. -->
<SetProperty Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER" /> <!-- Case 1: already ends with \$(var.Product)\, keep it unchanged. -->
<SetProperty Action="SetInstallFolderInnerFromProductDir" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)\&quot;" />
<!-- Case 2: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
<SetProperty Action="SetInstallFolderInnerFromProductDirNoSlash" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)&quot;" />
<!-- Case 3: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
<SetProperty Action="SetInstallFolderInnerAppendProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<!-- Case 4: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
<SetProperty Action="SetInstallFolderInnerAppendSlashProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND NOT INSTALLFOLDER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. --> <!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
<!-- Because we need to use INSTALLFOLDER as the command line argument. --> <!-- Because we need to use INSTALLFOLDER as the command line argument. -->

View File

@@ -12,7 +12,7 @@
</Component> </Component>
</DirectoryRef> </DirectoryRef>
<CustomAction Id="RemoveInstallFolder.SetParam" Return="check" Property="RemoveInstallFolder" Value="[INSTALLFOLDER_INNER]" /> <CustomAction Id="RemoveRuntimeGeneratedFiles.SetParam" Return="check" Property="RemoveRuntimeGeneratedFiles" Value="[INSTALLFOLDER_INNER]" />
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" /> <CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" /> <CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);&quot;[INSTALLFOLDER_INNER]$(var.Product).exe&quot; --service" /> <CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);&quot;[INSTALLFOLDER_INNER]$(var.Product).exe&quot; --service" />
@@ -67,7 +67,7 @@
Some msi packages reset the `VersionNT` value to 1000 on Windows 10. Some msi packages reset the `VersionNT` value to 1000 on Windows 10.
https://www.advancedinstaller.com/user-guide/qa-OS-dependent-install.html --> https://www.advancedinstaller.com/user-guide/qa-OS-dependent-install.html -->
<!-- Remote printer also works on Win8.1 in my test. --> <!-- 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" /> <Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT &gt;= 603" />
<!--Workaround of "fire:FirewallException". If Outbound="Yes" or Outbound="true", the following error occurs.--> <!--Workaround of "fire:FirewallException". If Outbound="Yes" or Outbound="true", the following error occurs.-->
@@ -77,21 +77,21 @@
<Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE=&quot;outgoing&quot;)"/> <Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE=&quot;outgoing&quot;)"/>
<Custom Action="RemoveInstallFolder" Before="RemoveFiles"/> <Custom Action="RemoveRuntimeGeneratedFiles" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot; OR UPGRADINGPRODUCTCODE)"/>
<Custom Action="RemoveInstallFolder.SetParam" Before="RemoveInstallFolder"/> <Custom Action="RemoveRuntimeGeneratedFiles.SetParam" Before="RemoveRuntimeGeneratedFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot; OR UPGRADINGPRODUCTCODE)"/>
<Custom Action="TryStopDeleteService" Before="RemoveInstallFolder.SetParam" /> <Custom Action="TryStopDeleteService" Before="RemoveRuntimeGeneratedFiles.SetParam" />
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" /> <Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/> <Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/> <Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT &gt;= 603" /> <Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT &gt;= 603" />
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/> <Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/> <Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/> <Custom Action="TerminateBrokers" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/> <Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
<Custom Action="RemoveAmyuniIdd" Before="RemoveInstallFolder"/> <Custom Action="RemoveAmyuniIdd" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/> <Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
</InstallExecuteSequence> </InstallExecuteSequence>

View File

@@ -5,7 +5,7 @@
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" /> <Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/> <CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="RemoveInstallFolder" DllEntry="RemoveInstallFolder" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/> <CustomAction Id="RemoveRuntimeGeneratedFiles" DllEntry="RemoveRuntimeGeneratedFiles" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/> <CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/> <CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/> <CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>

View File

@@ -4,17 +4,17 @@
<?include ..\Includes.wxi?> <?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 <!-- 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. install start menu shortcuts or the printer. Shortcut properties default to install;
They should not be set directly from the command line or registry, instead the CREATE* properties PRINTER defaults to not install. The CREATE* properties below update shortcut
below should be set, then they will update these properties with their values only if set. --> properties from command line, bundle, or registry values. -->
<Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property> <Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property> <Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="STARTUPSHORTCUTS" 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, <!-- These properties get set from either the command line, bundle or registry value,
if set they update the properties above with their 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 --> <!-- 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="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="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> </Fragment>
</Wix> </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 \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 \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 \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 \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 \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 \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; \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; \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; \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>
</IncludeSearchPaths> </IncludeSearchPaths>
<Configurations>Release</Configurations> <Configurations>Release</Configurations>
<Platforms>x64</Platforms> <Platforms>x64;ARM64</Platforms>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="Includes.wxi" /> <Content Include="Includes.wxi" />

View File

@@ -23,12 +23,13 @@ Patch dialog sequence:
--> -->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"> <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<?include ../Includes.wxi?>
<?foreach WIXUIARCH in X86;X64;A64 ?> <?foreach WIXUIARCH in X86;X64;A64 ?>
<Fragment> <Fragment>
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)"> <UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" /> <Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" /> <Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" /> <Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="5" Condition="NOT WIXUI_DONTVALIDATEPATH" />
</UI> </UI>
<UIRef Id="UI_MyInstallDialog" /> <UIRef Id="UI_MyInstallDialog" />
@@ -64,9 +65,16 @@ Patch dialog sequence:
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = &quot;1&quot;" /> <Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = &quot;1&quot;" />
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" /> <Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" /> <!-- Normalize INSTALLFOLDER_INNER before SetTargetPath and WixUIValidatePath run. -->
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID&lt;&gt;&quot;1&quot;" /> <!-- UI case 1: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID=&quot;1&quot;" /> <Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\" Order="1" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)&quot;" />
<!-- UI case 2: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]$(var.Product)\" Order="2" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<!-- UI case 3: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\$(var.Product)\" Order="3" Condition="INSTALLFOLDER_INNER AND NOT INSTALLFOLDER_INNER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="4" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="6" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID&lt;&gt;&quot;1&quot;" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="7" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID=&quot;1&quot;" />
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" /> <Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" /> <Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" /> <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" />

View File

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

View File

@@ -85,7 +85,7 @@ def make_parser():
"-m", "-m",
"--manufacturer", "--manufacturer",
type=str, type=str,
default="PURSLANE", default="Purslane Tech Pte. Ltd.",
help="The app manufacturer.", help="The app manufacturer.",
) )
return parser return parser
@@ -499,7 +499,7 @@ def update_license_file(app_name):
license_content = f.read() license_content = f.read()
license_content = license_content.replace("website rustdesk.com and other ", "") license_content = license_content.replace("website rustdesk.com and other ", "")
license_content = license_content.replace("RustDesk", app_name) 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: with open(license_file, "w", encoding="utf-8") as f:
f.write(license_content) f.write(license_content)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
echo $MACOS_CODESIGN_IDENTITY echo $MACOS_CODESIGN_IDENTITY
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
cd flutter; flutter pub get; cd - cd flutter; flutter pub get; cd -
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
./build.py --flutter ./build.py --flutter

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ pre_start()
return 0 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 # IF ~/.bash_logout exists THEN
# execute ~/.bash_logout # execute ~/.bash_logout

View File

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

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);
}

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